feat(mobile-next): 聊天输入区、语音气泡与大图按规范重排

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-20 03:51:27 +08:00
co-authored by Cursor multica-agent
parent 49b346d375
commit 3b87812db8
8 changed files with 267 additions and 87 deletions
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart'; import 'package:openim_common/openim_common.dart';
import 'app_tokens.dart'; import 'app_tokens.dart';
@@ -34,4 +35,73 @@ void applyOfficialStylesFromTokens() {
Styles.c_0C1C33_opacity60 = AppColors.textPrimary.withOpacity(.6); Styles.c_0C1C33_opacity60 = AppColors.textPrimary.withOpacity(.6);
Styles.c_0C1C33_opacity85 = AppColors.textPrimary.withOpacity(.85); Styles.c_0C1C33_opacity85 = AppColors.textPrimary.withOpacity(.85);
Styles.c_0C1C33_opacity80 = AppColors.textPrimary.withOpacity(.8); 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_10sp = TextStyle(
color: AppColors.danger,
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,
);
} }
@@ -105,15 +105,20 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
children: [ children: [
Container( Container(
constraints: BoxConstraints(minHeight: kInputBoxMinHeight), constraints: BoxConstraints(minHeight: kInputBoxMinHeight),
color: Styles.c_F0F2F6, decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border(
top: BorderSide(color: Styles.c_E8EAEF, width: 0.5),
),
),
child: Row( child: Row(
children: [ children: [
12.horizontalSpace, 12.horizontalSpace,
(_leftKeyboardButton (_leftKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapLeftKeyboard) ? (ImageRes.openKeyboard.toImage..onTap = onTapLeftKeyboard)
: (ImageRes.openVoice.toImage..onTap = onTapSpeak)) : (ImageRes.openVoice.toImage..onTap = onTapSpeak))
..width = 32.w ..width = 24.w
..height = 32.h ..height = 24.h
..opacity = _opacity, ..opacity = _opacity,
12.horizontalSpace, 12.horizontalSpace,
Expanded( Expanded(
@@ -134,15 +139,33 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
(_rightKeyboardButton (_rightKeyboardButton
? (ImageRes.openKeyboard.toImage..onTap = onTapRightKeyboard) ? (ImageRes.openKeyboard.toImage..onTap = onTapRightKeyboard)
: (ImageRes.openEmoji.toImage..onTap = onTapEmoji)) : (ImageRes.openEmoji.toImage..onTap = onTapEmoji))
..width = 32.w ..width = 24.w
..height = 32.h ..height = 24.h
..opacity = _opacity, ..opacity = _opacity,
12.horizontalSpace, 12.horizontalSpace,
(_sendButtonVisible ? ImageRes.sendMessage : ImageRes.openToolbox).toImage _sendButtonVisible
..width = 32.w ? GestureDetector(
..height = 32.h onTap: send,
..opacity = _opacity child: Opacity(
..onTap = _sendButtonVisible ? send : toggleToolbox, 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),
12.horizontalSpace, 12.horizontalSpace,
], ],
), ),
@@ -178,10 +201,11 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
} }
Widget get _textFiled => Container( Widget get _textFiled => Container(
margin: EdgeInsets.only(top: 10.h, bottom: _showQuoteView ? 4.h : 10.h), constraints: const BoxConstraints(minHeight: 40),
margin: EdgeInsets.only(top: 8.h, bottom: _showQuoteView ? 4.h : 8.h),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Styles.c_FFFFFF, color: Styles.c_F0F2F6,
borderRadius: BorderRadius.circular(4.r), borderRadius: BorderRadius.circular(8.r),
), ),
child: ChatTextField( child: ChatTextField(
controller: widget.controller, controller: widget.controller,
@@ -8,8 +8,8 @@ import 'package:focus_detector_v2/focus_detector_v2.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';
double maxWidth = 247.w; double maxWidth = 0.65.sw;
double pictureWidth = 120.w; double pictureWidth = 200;
double videoWidth = 120.w; double videoWidth = 120.w;
double locationWidth = 220.w; double locationWidth = 220.w;
@@ -30,14 +30,28 @@ class ChatPicturePreview extends StatelessWidget {
offset: offset, offset: offset,
pageSize: pageSize, pageSize: pageSize,
), ),
child: MetaHero( child: Stack(
heroTag: heroTag, children: [
onTap: onTap ?? () => Get.back(), MetaHero(
onLongPress: () { heroTag: heroTag,
final index = controller?.page?.round() ?? 0; onTap: onTap ?? () => Get.back(),
onLongPress?.call(images[index].url!); onLongPress: () {
}, final index = controller?.page?.round() ?? currentIndex;
child: _childView, 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,
),
),
],
), ),
); );
} }
@@ -79,19 +93,14 @@ class ChatPicturePreview extends StatelessWidget {
if (source.url?.isVideoFileName == true) { if (source.url?.isVideoFileName == true) {
return null; return null;
} }
final ImageChunkEvent? loadingProgress = state.loadingProgress;
final double? progress = loadingProgress?.expectedTotalBytes != null
? loadingProgress!.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null;
return SizedBox( return const Center(
width: 15.0, child: SizedBox(
height: 15.0, width: 24,
child: Center( height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Styles.c_0089FF, color: Styles.c_0089FF,
strokeWidth: 1.5, strokeWidth: 2,
value: progress ?? 0,
), ),
), ),
); );
@@ -110,7 +119,23 @@ class ChatPicturePreview extends StatelessWidget {
); );
case LoadState.failed: case LoadState.failed:
state.imageProvider.evict(); state.imageProvider.evict();
return ImageRes.pictureError.toImage; 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)),
],
),
);
} }
}, },
); );
@@ -140,3 +165,50 @@ class MetaHero extends StatelessWidget {
return heroTag == null ? view : Hero(tag: heroTag!, child: view); 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,12 +44,20 @@ class _ChatPictureViewState extends State<ChatPictureView> {
var h = picture?.sourcePicture?.height?.toDouble() ?? 1.0; var h = picture?.sourcePicture?.height?.toDouble() ?? 1.0;
if (pictureWidth > w) { if (pictureWidth > w) {
_trulyWidth = w; _trulyWidth = w < 80 ? 80 : w;
_trulyHeight = h; _trulyHeight = _trulyWidth * h / w;
} else { } else {
_trulyWidth = pictureWidth; _trulyWidth = pictureWidth;
_trulyHeight = _trulyWidth * h / w; _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; final height = pictureWidth * 1.sh / 1.sw;
@@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart'; import 'package:openim_common/openim_common.dart';
double kVoiceRecordBarHeight = 36.h; double kVoiceRecordBarHeight = 40;
enum RecordBarStatus { enum RecordBarStatus {
holdTalk, holdTalk,
@@ -39,7 +39,7 @@ class ChatVoiceRecordBar extends StatefulWidget {
class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> { class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
bool _pressing = false; bool _pressing = false;
bool _canCancel = false; bool _canCancel = false;
final double _offset = 40.h; final double _offset = 60;
StreamSubscription? _sub; StreamSubscription? _sub;
@override @override
@@ -106,12 +106,15 @@ class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
height: kVoiceRecordBarHeight, height: kVoiceRecordBarHeight,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.speakBarColor ?? (_pressing ? Styles.c_8E9AB0_opacity30 : Styles.c_FFFFFF), color: widget.speakBarColor ??
borderRadius: BorderRadius.circular(4.r), (_pressing ? Styles.c_F2F8FF : Styles.c_F0F2F6),
borderRadius: BorderRadius.circular(8.r),
), ),
child: Text( child: Text(
_pressing ? (_canCancel ? StrRes.liftFingerToCancelSend : StrRes.releaseToSend) : StrRes.holdTalk, _pressing
style: widget.speakTextStyle ?? Styles.ts_0C1C33_14sp_medium, ? (_canCancel ? StrRes.liftFingerToCancelSend : StrRes.releaseToSend)
: StrRes.holdTalk,
style: widget.speakTextStyle ?? Styles.ts_0C1C33_17sp,
), ),
), ),
); );
@@ -106,20 +106,34 @@ class _ChatVoiceRecordLayoutState extends State<ChatVoiceRecordLayout> {
builder: (_, sec) => Material( builder: (_, sec) => Material(
color: Colors.transparent, color: Colors.transparent,
child: Center( child: Center(
child: Container( child: AnimatedContainer(
width: 138.w, duration: const Duration(milliseconds: 100),
height: 124.h, curve: Curves.easeOut,
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 6.h), width: 160.w,
height: 140.h,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isCancelSend ? Styles.c_FF381F_opacity70 : Styles.c_0C1C33_opacity60, color: _isCancelSend
borderRadius: BorderRadius.circular(6.r), ? Styles.c_FF381F_opacity70
: Styles.c_000000_opacity70,
borderRadius: BorderRadius.circular(12.r),
), ),
child: Column( child: Column(
children: [ children: [
IMUtils.seconds2HMS(sec).toText..style = Styles.ts_FFFFFF_12sp, IMUtils.seconds2HMS(sec).toText
Expanded(child: _lottieAnimWidget),
(_isCancelSend ? StrRes.liftFingerToCancelSend : StrRes.releaseToSendSwipeUpToCancel).toText
..style = Styles.ts_FFFFFF_12sp, ..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,
], ],
), ),
), ),
@@ -18,58 +18,47 @@ class ChatVoiceView extends StatelessWidget {
this.isPlaying = false, this.isPlaying = false,
}) : super(key: key); }) : 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() { 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 return isISend
? Row( ? Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
'${duration ?? 0}``'.toText..style = Styles.ts_0089FF_17sp, seconds,
4.horizontalSpace, 8.horizontalSpace,
RotatedBox( RotatedBox(quarterTurns: 90, child: icon),
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( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isPlaying) icon,
ImageRes.voiceBlueAnim.toLottie 8.horizontalSpace,
..width = 24.w seconds,
..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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return SizedBox(
margin: EdgeInsets.only( width: _bubbleWidth,
left: isISend ? _margin : 0,
right: !isISend ? _margin : 0,
),
child: _buildVoiceAnimView(), child: _buildVoiceAnimView(),
); );
} }
double get _margin {
final maxWidth = 100.w;
const maxDuration = 60;
double diff = (duration ?? 0) * maxWidth / maxDuration;
return diff > maxWidth ? maxWidth : diff;
}
} }