feat(mobile-next): 聊天输入区、语音气泡与大图按规范重排
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
49b346d375
commit
3b87812db8
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
|
||||
import 'app_tokens.dart';
|
||||
@@ -34,4 +35,73 @@ void applyOfficialStylesFromTokens() {
|
||||
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_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: [
|
||||
Container(
|
||||
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(
|
||||
children: [
|
||||
12.horizontalSpace,
|
||||
(_leftKeyboardButton
|
||||
? (ImageRes.openKeyboard.toImage..onTap = onTapLeftKeyboard)
|
||||
: (ImageRes.openVoice.toImage..onTap = onTapSpeak))
|
||||
..width = 32.w
|
||||
..height = 32.h
|
||||
..width = 24.w
|
||||
..height = 24.h
|
||||
..opacity = _opacity,
|
||||
12.horizontalSpace,
|
||||
Expanded(
|
||||
@@ -134,15 +139,33 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
|
||||
(_rightKeyboardButton
|
||||
? (ImageRes.openKeyboard.toImage..onTap = onTapRightKeyboard)
|
||||
: (ImageRes.openEmoji.toImage..onTap = onTapEmoji))
|
||||
..width = 32.w
|
||||
..height = 32.h
|
||||
..width = 24.w
|
||||
..height = 24.h
|
||||
..opacity = _opacity,
|
||||
12.horizontalSpace,
|
||||
(_sendButtonVisible ? ImageRes.sendMessage : ImageRes.openToolbox).toImage
|
||||
..width = 32.w
|
||||
..height = 32.h
|
||||
..opacity = _opacity
|
||||
..onTap = _sendButtonVisible ? send : toggleToolbox,
|
||||
_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),
|
||||
12.horizontalSpace,
|
||||
],
|
||||
),
|
||||
@@ -178,10 +201,11 @@ class _ChatInputBoxState extends State<ChatInputBox> /*with TickerProviderStateM
|
||||
}
|
||||
|
||||
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(
|
||||
color: Styles.c_FFFFFF,
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
color: Styles.c_F0F2F6,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: ChatTextField(
|
||||
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:rxdart/rxdart.dart';
|
||||
|
||||
double maxWidth = 247.w;
|
||||
double pictureWidth = 120.w;
|
||||
double maxWidth = 0.65.sw;
|
||||
double pictureWidth = 200;
|
||||
double videoWidth = 120.w;
|
||||
double locationWidth = 220.w;
|
||||
|
||||
|
||||
@@ -30,14 +30,28 @@ class ChatPicturePreview extends StatelessWidget {
|
||||
offset: offset,
|
||||
pageSize: pageSize,
|
||||
),
|
||||
child: MetaHero(
|
||||
heroTag: heroTag,
|
||||
onTap: onTap ?? () => Get.back(),
|
||||
onLongPress: () {
|
||||
final index = controller?.page?.round() ?? 0;
|
||||
onLongPress?.call(images[index].url!);
|
||||
},
|
||||
child: _childView,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -79,19 +93,14 @@ 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 SizedBox(
|
||||
width: 15.0,
|
||||
height: 15.0,
|
||||
child: Center(
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Styles.c_0089FF,
|
||||
strokeWidth: 1.5,
|
||||
value: progress ?? 0,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -110,7 +119,23 @@ class ChatPicturePreview extends StatelessWidget {
|
||||
);
|
||||
case LoadState.failed:
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (pictureWidth > w) {
|
||||
_trulyWidth = w;
|
||||
_trulyHeight = h;
|
||||
_trulyWidth = w < 80 ? 80 : w;
|
||||
_trulyHeight = _trulyWidth * h / w;
|
||||
} 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;
|
||||
|
||||
|
||||
@@ -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 = 36.h;
|
||||
double kVoiceRecordBarHeight = 40;
|
||||
|
||||
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 = 40.h;
|
||||
final double _offset = 60;
|
||||
StreamSubscription? _sub;
|
||||
|
||||
@override
|
||||
@@ -106,12 +106,15 @@ class _ChatVoiceRecordBarState extends State<ChatVoiceRecordBar> {
|
||||
height: kVoiceRecordBarHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.speakBarColor ?? (_pressing ? Styles.c_8E9AB0_opacity30 : Styles.c_FFFFFF),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
color: widget.speakBarColor ??
|
||||
(_pressing ? Styles.c_F2F8FF : Styles.c_F0F2F6),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Text(
|
||||
_pressing ? (_canCancel ? StrRes.liftFingerToCancelSend : StrRes.releaseToSend) : StrRes.holdTalk,
|
||||
style: widget.speakTextStyle ?? Styles.ts_0C1C33_14sp_medium,
|
||||
_pressing
|
||||
? (_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(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 138.w,
|
||||
height: 124.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 6.h),
|
||||
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_0C1C33_opacity60,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
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: _lottieAnimWidget),
|
||||
(_isCancelSend ? StrRes.liftFingerToCancelSend : StrRes.releaseToSendSwipeUpToCancel).toText
|
||||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -18,58 +18,47 @@ class ChatVoiceView extends StatelessWidget {
|
||||
this.isPlaying = 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: [
|
||||
'${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),
|
||||
),
|
||||
seconds,
|
||||
8.horizontalSpace,
|
||||
RotatedBox(quarterTurns: 90, child: icon),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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,
|
||||
icon,
|
||||
8.horizontalSpace,
|
||||
seconds,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: isISend ? _margin : 0,
|
||||
right: !isISend ? _margin : 0,
|
||||
),
|
||||
return SizedBox(
|
||||
width: _bubbleWidth,
|
||||
child: _buildVoiceAnimView(),
|
||||
);
|
||||
}
|
||||
|
||||
double get _margin {
|
||||
final maxWidth = 100.w;
|
||||
const maxDuration = 60;
|
||||
double diff = (duration ?? 0) * maxWidth / maxDuration;
|
||||
return diff > maxWidth ? maxWidth : diff;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user