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
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user