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:
编码工程师
2026-08-20 04:22:06 +08:00
co-authored by Cursor multica-agent
parent e40ece184b
commit 15d7f1935e
14 changed files with 233 additions and 78 deletions
@@ -204,7 +204,7 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
constraints: const BoxConstraints(minHeight: 40),
margin: EdgeInsets.only(top: 8.h, bottom: _showQuoteView ? 4.h : 8.h),
decoration: BoxDecoration(
color: Styles.c_F0F2F6,
color: Styles.c_F2F3F5,
borderRadius: BorderRadius.circular(8.r),
),
child: ChatTextField(
@@ -251,7 +251,7 @@ class _ChatItemViewState extends State<ChatItemView> {
message: _message,
);
} else if (_message.isVoiceType) {
isBubbleBg = true;
isBubbleBg = false;
final sound = _message.soundElem;
child = ChatVoiceView(
isISend: _isISend,
@@ -259,6 +259,7 @@ 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) ??
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class ChatVoiceReadStatusView extends StatelessWidget {
@@ -7,8 +6,8 @@ class ChatVoiceReadStatusView extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
width: 6.w,
height: 6.w,
width: 8,
height: 8,
decoration: BoxDecoration(
color: Styles.c_FF381F,
shape: BoxShape.circle,
@@ -107,7 +107,7 @@ class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
alignment: Alignment.center,
decoration: BoxDecoration(
color: widget.speakBarColor ??
(_pressing ? Styles.c_F2F8FF : Styles.c_F0F2F6),
(_pressing ? Styles.c_F2F8FF : Styles.c_F2F3F5),
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
@@ -5,6 +5,7 @@ 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);
@@ -36,9 +37,12 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
bool _showVoiceRecordView = false;
RecordBarStatus _status = RecordBarStatus.holdTalk;
bool _isCancelSend = false;
bool _autoSendByLimit = false;
void _completed(int sec, String path) {
if (_isCancelSend) {
final autoSend = _autoSendByLimit;
_autoSendByLimit = false;
if (_isCancelSend && !autoSend) {
File(path).delete();
_status = RecordBarStatus.holdTalk;
_isCancelSend = false;
@@ -48,6 +52,9 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
IMViews.showToast(StrRes.talkTooShort);
} else {
widget.onCompleted?.call(sec, path);
if (autoSend) {
IMViews.showToast(StrRes.autoSendMaxVoiceDuration);
}
}
}
}
@@ -83,6 +90,8 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
},
onLongPressStart: (details) {
setState(() {
_autoSendByLimit = false;
_isCancelSend = false;
_showVoiceRecordView = true;
});
},
@@ -93,61 +102,106 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
return Stack(
children: [
widget.builder(_createSpeakBar),
Visibility(
visible: _showVoiceRecordView,
child: ChatRecordVoiceView(
onCompleted: _completed,
onInterrupt: () {
setState(() {
_interruptSub.add(true);
_showVoiceRecordView = false;
});
},
builder: (_, sec) => Material(
color: Colors.transparent,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
width: 160.w,
height: 140.h,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
color: _isCancelSend
? Styles.c_FF381F_opacity70
: Styles.c_000000_opacity70,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
children: [
IMUtils.seconds2HMS(sec).toText
..style = Styles.ts_FFFFFF_12sp,
Expanded(
child: _isCancelSend
? Icon(Icons.delete_outline,
size: 48.w, color: Styles.c_FFFFFF)
: _lottieAnimWidget,
),
(_isCancelSend
? StrRes.liftFingerToCancelSend
: StrRes.releaseToSendSwipeUpToCancel)
.toText
..style = Styles.ts_FFFFFF_12sp
..maxLines = 1,
],
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,
],
),
),
),
),
),
),
),
],
);
}
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,11 +24,13 @@ class ChatRecordVoiceView extends StatefulWidget {
class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
static const _dir = "voice";
static const _ext = ".m4a";
late String _path;
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;
@@ -37,16 +39,28 @@ class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
(() async {
if (await _audioRecorder.hasPermission()) {
_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();
_timer?.cancel();
_timer = null;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) async {
_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;
}
setState(() {
_duration = ((_now() - _startTimestamp) ~/ 1000);
if (_duration >= widget.maxRecordSec) {
widget.onInterrupt?.call();
}
_duration = duration;
});
});
}
@@ -54,17 +68,32 @@ class _ChatRecordVoiceViewState extends State<ChatRecordVoiceView> {
super.initState();
}
@override
void dispose() {
(() async {
_timer?.cancel();
_timer = null;
void _interruptByLimit() {
if (_interrupted) return;
_interrupted = true;
widget.onInterrupt?.call();
}
Future<void> _stopAndComplete() async {
if (_completed) return;
_completed = true;
_timer?.cancel();
_timer = null;
try {
if (await _audioRecorder.isRecording()) {
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();
}
@@ -8,6 +8,7 @@ class ChatVoiceView extends StatelessWidget {
final String? soundUrl;
final int? duration;
final bool isPlaying;
final bool isUnread;
const ChatVoiceView({
Key? key,
@@ -16,6 +17,7 @@ class ChatVoiceView extends StatelessWidget {
this.soundUrl,
this.duration,
this.isPlaying = false,
this.isUnread = false,
}) : super(key: key);
double get _bubbleWidth {
@@ -56,9 +58,22 @@ class ChatVoiceView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: _bubbleWidth,
child: _buildVoiceAnimView(),
final bubble = ChatBubble(
bubbleType: isISend ? BubbleType.send : BubbleType.receiver,
child: SizedBox(
width: _bubbleWidth,
child: _buildVoiceAnimView(),
),
);
if (!isUnread) return bubble;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
bubble,
8.horizontalSpace,
const ChatVoiceReadStatusView(),
],
);
}
}