新增 mobile/:畅联手机端 Flutter 工程(B-58)
- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 头像(按效果图):
|
||||
/// - 单人是圆角方块 + 姓氏首字,蓝 / 灰蓝两色按名字 hash 取色;
|
||||
/// - 群聊是 2x2 小方块拼格;
|
||||
/// - 有 faceURL 时优先显示网络头像,加载失败回退到首字。
|
||||
class NameAvatar extends StatelessWidget {
|
||||
final String name;
|
||||
final String? faceURL;
|
||||
final double size;
|
||||
|
||||
/// 是否群聊样式(2x2 拼格)
|
||||
final bool isGroup;
|
||||
|
||||
const NameAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.faceURL,
|
||||
this.size = 48,
|
||||
this.isGroup = false,
|
||||
});
|
||||
|
||||
Color _colorFor(String text) {
|
||||
var hash = 0;
|
||||
for (final code in text.codeUnits) {
|
||||
hash = (hash * 31 + code) & 0x7fffffff;
|
||||
}
|
||||
return hash % 2 == 0 ? AppColors.primary : AppColors.avatarGray;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = BorderRadius.circular(size * 0.18);
|
||||
if (isGroup) return _groupAvatar(radius);
|
||||
|
||||
final hasFace = faceURL != null && faceURL!.isNotEmpty;
|
||||
final letter = name.isNotEmpty ? name.substring(0, 1) : '';
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: _colorFor(name),
|
||||
alignment: Alignment.center,
|
||||
child: hasFace
|
||||
? Image.network(
|
||||
faceURL!,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _letterText(letter),
|
||||
loadingBuilder: (_, child, progress) => progress == null ? child : _letterText(letter),
|
||||
)
|
||||
: _letterText(letter),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _letterText(String letter) {
|
||||
return Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 群头像:浅灰底上 2x2 小方块,蓝 / 灰蓝交替
|
||||
Widget _groupAvatar(BorderRadius radius) {
|
||||
final cell = (size - 10) / 2;
|
||||
const colors = [AppColors.primary, AppColors.avatarGray, AppColors.avatarGray, AppColors.primary];
|
||||
return ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: AppColors.searchBg,
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Wrap(
|
||||
spacing: 2,
|
||||
runSpacing: 2,
|
||||
children: List.generate(
|
||||
4,
|
||||
(i) => Container(
|
||||
width: cell,
|
||||
height: cell,
|
||||
decoration: BoxDecoration(
|
||||
color: colors[i],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 聊天底部输入栏:
|
||||
/// 左侧麦克风/键盘切换、中间输入框(语音模式变成「按住 说话」)、右侧表情和加号。
|
||||
/// 有文字时加号变成「发送」按钮(微信式交互)。
|
||||
class ChatInputBar extends StatefulWidget {
|
||||
/// 发送文字
|
||||
final void Function(String text) onSendText;
|
||||
|
||||
/// 发送语音(本地文件路径 + 秒数)
|
||||
final void Function(String path, int duration) onSendVoice;
|
||||
|
||||
/// 从相册选图片发送
|
||||
final VoidCallback onPickImage;
|
||||
|
||||
/// 选文件发送
|
||||
final VoidCallback onPickFile;
|
||||
|
||||
/// 发起语音通话(群聊为 null,不显示该入口)
|
||||
final VoidCallback? onVoiceCall;
|
||||
|
||||
const ChatInputBar({
|
||||
super.key,
|
||||
required this.onSendText,
|
||||
required this.onSendVoice,
|
||||
required this.onPickImage,
|
||||
required this.onPickFile,
|
||||
this.onVoiceCall,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatInputBar> createState() => _ChatInputBarState();
|
||||
}
|
||||
|
||||
class _ChatInputBarState extends State<ChatInputBar> {
|
||||
final TextEditingController _ctrl = TextEditingController();
|
||||
final FocusNode _focus = FocusNode();
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
|
||||
bool _voiceMode = false;
|
||||
bool _panelOpen = false;
|
||||
bool _recording = false;
|
||||
bool _hasText = false;
|
||||
String? _recordPath;
|
||||
int _recordStart = 0;
|
||||
Timer? _recordTimer;
|
||||
|
||||
/// 最长录音 60 秒
|
||||
static const int _maxRecordSec = 60;
|
||||
|
||||
/// 常用表情(表情按钮点开的小面板,不加第三方表情包)
|
||||
static const List<String> _emojis = [
|
||||
'😀', '😄', '😂', '🤣', '😊', '😍', '🤔', '😅',
|
||||
'👍', '👌', '🙏', '👏', '💪', '🎉', '❤️', '😢',
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recordTimer?.cancel();
|
||||
_recorder.dispose();
|
||||
_ctrl.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendText() {
|
||||
final text = _ctrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
widget.onSendText(text);
|
||||
_ctrl.clear();
|
||||
setState(() => _hasText = false);
|
||||
}
|
||||
|
||||
// ---------------- 录音 ----------------
|
||||
|
||||
Future<void> _startRecord() async {
|
||||
try {
|
||||
if (!await _recorder.hasPermission()) {
|
||||
_toast('需要麦克风权限才能发语音');
|
||||
return;
|
||||
}
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = '${dir.path}/voice/${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
await File(path).create(recursive: true);
|
||||
await _recorder.start(const RecordConfig(), path: path);
|
||||
_recordPath = path;
|
||||
_recordStart = DateTime.now().millisecondsSinceEpoch;
|
||||
setState(() => _recording = true);
|
||||
_recordTimer?.cancel();
|
||||
_recordTimer = Timer(const Duration(seconds: _maxRecordSec), () => _stopRecord(send: true));
|
||||
} catch (_) {
|
||||
_toast('录音启动失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecord({required bool send}) async {
|
||||
_recordTimer?.cancel();
|
||||
_recordTimer = null;
|
||||
if (!await _recorder.isRecording()) {
|
||||
if (mounted) setState(() => _recording = false);
|
||||
return;
|
||||
}
|
||||
await _recorder.stop();
|
||||
if (mounted) setState(() => _recording = false);
|
||||
final path = _recordPath;
|
||||
if (!send || path == null) return;
|
||||
final duration = (DateTime.now().millisecondsSinceEpoch - _recordStart) ~/ 1000;
|
||||
if (duration < 1) {
|
||||
_toast('说话时间太短');
|
||||
return;
|
||||
}
|
||||
widget.onSendVoice(path, duration);
|
||||
}
|
||||
|
||||
void _toast(String text) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2)));
|
||||
}
|
||||
|
||||
// ---------------- 表情面板 ----------------
|
||||
|
||||
void _showEmojiPanel() {
|
||||
_focus.unfocus();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 8,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(AppGap.x3),
|
||||
children: _emojis
|
||||
.map(
|
||||
(e) => InkWell(
|
||||
onTap: () {
|
||||
_ctrl.text += e;
|
||||
_ctrl.selection = TextSelection.collapsed(offset: _ctrl.text.length);
|
||||
setState(() => _hasText = true);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: Center(child: Text(e, style: const TextStyle(fontSize: 24))),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 界面 ----------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
color: AppColors.pageBg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x2),
|
||||
child: Row(
|
||||
children: [
|
||||
// 麦克风 / 键盘切换
|
||||
_circleButton(
|
||||
_voiceMode ? Icons.keyboard_outlined : Icons.mic_none,
|
||||
() => setState(() => _voiceMode = !_voiceMode),
|
||||
),
|
||||
const SizedBox(width: AppGap.x2),
|
||||
Expanded(child: _voiceMode ? _holdToTalk() : _textField()),
|
||||
const SizedBox(width: AppGap.x2),
|
||||
_circleButton(Icons.sentiment_satisfied_alt, _showEmojiPanel),
|
||||
const SizedBox(width: AppGap.x2),
|
||||
if (_hasText)
|
||||
FilledButton(
|
||||
onPressed: _sendText,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4),
|
||||
minimumSize: const Size(0, 40),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('发送', style: TextStyle(fontSize: AppFont.sub)),
|
||||
)
|
||||
else
|
||||
_circleButton(Icons.add_circle_outline, () {
|
||||
_focus.unfocus();
|
||||
setState(() => _panelOpen = !_panelOpen);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_panelOpen) _plusPanel(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _circleButton(IconData icon, VoidCallback onTap) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppGap.x1),
|
||||
child: Icon(icon, size: 28, color: AppColors.textPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _textField() {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.searchBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focus,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendText(),
|
||||
onChanged: (v) => setState(() => _hasText = v.trim().isNotEmpty),
|
||||
onTap: () => setState(() => _panelOpen = false),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: 10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 语音模式下的「按住 说话」按钮
|
||||
Widget _holdToTalk() {
|
||||
return GestureDetector(
|
||||
onLongPressStart: (_) => _startRecord(),
|
||||
onLongPressEnd: (_) => _stopRecord(send: true),
|
||||
onLongPressCancel: () => _stopRecord(send: false),
|
||||
child: Container(
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _recording ? AppColors.primary : AppColors.searchBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_recording ? '松开 发送' : '按住 说话',
|
||||
style: TextStyle(
|
||||
fontSize: AppFont.body,
|
||||
color: _recording ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加号面板:图片 / 文件 / 语音通话(单聊)
|
||||
Widget _plusPanel() {
|
||||
return Container(
|
||||
color: AppColors.chatBg,
|
||||
padding: const EdgeInsets.all(AppGap.x6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_panelItem(Icons.image_outlined, '图片', widget.onPickImage),
|
||||
_panelItem(Icons.insert_drive_file_outlined, '文件', widget.onPickFile),
|
||||
if (widget.onVoiceCall != null) _panelItem(Icons.phone_outlined, '语音通话', widget.onVoiceCall!),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _panelItem(IconData icon, String label, VoidCallback onTap) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: AppGap.x6),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() => _panelOpen = false);
|
||||
onTap();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pageBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, size: 28, color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: AppGap.x1),
|
||||
Text(label, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import '../utils/format.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
/// 聊天页的消息气泡:文字 / 语音条 / 文件卡片 / 图片四种,
|
||||
/// 外加发送中(小菊花)与发送失败(红色感叹号)两种状态。
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
/// 是否己方发送
|
||||
final bool isMine;
|
||||
|
||||
/// 己方头像信息
|
||||
final String selfName;
|
||||
final String? selfFaceURL;
|
||||
|
||||
/// 对方头像信息(群聊里每条消息带各自发送者信息)
|
||||
final String peerName;
|
||||
final String? peerFaceURL;
|
||||
|
||||
/// 群聊中是否显示发送者昵称(对方消息)
|
||||
final bool showSenderName;
|
||||
|
||||
/// 点击语音气泡(播放)
|
||||
final void Function(Message message)? onTapVoice;
|
||||
|
||||
/// 点击文件气泡(打开 / 下载)
|
||||
final void Function(Message message)? onTapFile;
|
||||
|
||||
/// 点击红色感叹号重发
|
||||
final void Function(Message message)? onResend;
|
||||
|
||||
/// 正在播放的语音消息 ID(用于显示播放中状态,可为空)
|
||||
final String? playingVoiceId;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isMine,
|
||||
this.selfName = '',
|
||||
this.selfFaceURL,
|
||||
this.peerName = '',
|
||||
this.peerFaceURL,
|
||||
this.showSenderName = false,
|
||||
this.onTapVoice,
|
||||
this.onTapFile,
|
||||
this.onResend,
|
||||
this.playingVoiceId,
|
||||
});
|
||||
|
||||
bool get _failed => message.status == MessageStatus.failed;
|
||||
bool get _sending => message.status == MessageStatus.sending;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatar = NameAvatar(
|
||||
name: isMine ? selfName : (message.senderNickname?.isNotEmpty == true ? message.senderNickname! : peerName),
|
||||
faceURL: isMine ? selfFaceURL : (message.senderFaceUrl?.isNotEmpty == true ? message.senderFaceUrl : peerFaceURL),
|
||||
size: 40,
|
||||
);
|
||||
|
||||
final bubble = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65),
|
||||
child: _buildContent(context),
|
||||
);
|
||||
|
||||
final statusIcon = _buildStatus();
|
||||
|
||||
final row = Row(
|
||||
mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: isMine
|
||||
? [statusIcon, Flexible(child: bubble), const SizedBox(width: AppGap.x2), avatar]
|
||||
: [avatar, const SizedBox(width: AppGap.x2), Flexible(child: bubble), statusIcon],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2),
|
||||
child: Column(
|
||||
crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showSenderName && !isMine && (message.senderNickname?.isNotEmpty == true))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 48, bottom: AppGap.x1),
|
||||
child: Text(message.senderNickname!, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)),
|
||||
),
|
||||
row,
|
||||
// 发送失败提示(效果图:气泡下方灰字)
|
||||
if (_failed)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: AppGap.x1),
|
||||
child: Text(
|
||||
'消息没发出去,点红色感叹号重发',
|
||||
style: TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 发送中小菊花 / 失败红色感叹号
|
||||
Widget _buildStatus() {
|
||||
if (_sending) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x3),
|
||||
child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.textSecondary)),
|
||||
);
|
||||
}
|
||||
if (_failed) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x2, vertical: AppGap.x3),
|
||||
child: GestureDetector(
|
||||
onTap: () => onResend?.call(message),
|
||||
child: const Icon(Icons.error, size: 20, color: AppColors.danger),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
switch (message.contentType) {
|
||||
case MessageType.voice:
|
||||
return _voiceBubble();
|
||||
case MessageType.file:
|
||||
return _fileCard();
|
||||
case MessageType.picture:
|
||||
return _imageBubble();
|
||||
case MessageType.custom:
|
||||
return _textBubble(FormatUtils.messagePreview(message, isGroup: false));
|
||||
default:
|
||||
if ((message.contentType ?? 0) >= MessageType.notificationBegin) {
|
||||
// 通知类消息:居中灰字(如入群通知)
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _textBubble(message.textElem?.content ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/// 文字气泡:己方浅蓝、对方白色
|
||||
Widget _textBubble(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2),
|
||||
decoration: BoxDecoration(
|
||||
color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(text, style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 语音气泡:喇叭图标 + 秒数;未读语音右侧小红点
|
||||
Widget _voiceBubble() {
|
||||
final duration = message.soundElem?.duration ?? 0;
|
||||
final playing = playingVoiceId != null && playingVoiceId == message.clientMsgID;
|
||||
// 宽度随时长增加,封顶
|
||||
final width = 72.0 + (duration > 30 ? 60 : duration * 2);
|
||||
final unread = !isMine && message.isRead == false;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => onTapVoice?.call(message),
|
||||
child: Container(
|
||||
width: width,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2),
|
||||
decoration: BoxDecoration(
|
||||
color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: isMine
|
||||
? [
|
||||
Text('$duration″', style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
|
||||
const SizedBox(width: AppGap.x2),
|
||||
Icon(playing ? Icons.volume_up : Icons.volume_up_outlined, size: 20, color: AppColors.textPrimary),
|
||||
]
|
||||
: [
|
||||
Icon(playing ? Icons.volume_up : Icons.volume_up_outlined, size: 20, color: AppColors.textPrimary),
|
||||
const SizedBox(width: AppGap.x2),
|
||||
Text('$duration″', style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (unread)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(left: AppGap.x1),
|
||||
decoration: const BoxDecoration(color: AppColors.danger, shape: BoxShape.circle),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 文件气泡:卡片样式(文件图标、文件名、大小)
|
||||
Widget _fileCard() {
|
||||
final name = message.fileElem?.fileName ?? '未知文件';
|
||||
final size = FormatUtils.fileSize(message.fileElem?.fileSize);
|
||||
return GestureDetector(
|
||||
onTap: () => onTapFile?.call(message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppGap.x3),
|
||||
decoration: BoxDecoration(
|
||||
color: isMine ? AppColors.bubbleMine : AppColors.bubbleOther,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.pageBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.insert_drive_file_outlined, size: 28, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: AppGap.x2),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: AppGap.x1),
|
||||
Text(size, style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 图片气泡:本地优先,其次网络图
|
||||
Widget _imageBubble() {
|
||||
final localPath = message.pictureElem?.sourcePath;
|
||||
final url = message.pictureElem?.snapshotPicture?.url ?? message.pictureElem?.sourcePicture?.url;
|
||||
Widget child;
|
||||
if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) {
|
||||
child = Image.file(File(localPath), fit: BoxFit.cover);
|
||||
} else if (url != null && url.isNotEmpty) {
|
||||
child = Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _imagePlaceholder(),
|
||||
loadingBuilder: (_, c, p) => p == null ? c : _imagePlaceholder(),
|
||||
);
|
||||
} else {
|
||||
child = _imagePlaceholder();
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 160, maxHeight: 200),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder() {
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
color: AppColors.searchBg,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.image_outlined, size: 40, color: AppColors.textSecondary),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 消息列表 / 通讯录共用的搜索框(效果图 m1/m3):
|
||||
/// 未输入时「放大镜 + 搜索」居中显示,输入后正常左对齐。
|
||||
class SearchBox extends StatefulWidget {
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const SearchBox({super.key, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<SearchBox> createState() => _SearchBoxState();
|
||||
}
|
||||
|
||||
class _SearchBoxState extends State<SearchBox> {
|
||||
final TextEditingController _ctrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2),
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _ctrl,
|
||||
onChanged: (v) {
|
||||
setState(() {});
|
||||
widget.onChanged(v.trim());
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: AppColors.searchBg,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: AppGap.x3, vertical: AppGap.x2),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 空内容时的居中占位(不拦截点击,点按穿透到输入框)
|
||||
if (_ctrl.text.isEmpty)
|
||||
const IgnorePointer(
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||
SizedBox(width: AppGap.x1),
|
||||
Text('搜索', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
|
||||
/// 空 / 加载中 / 加载失败 / 断网 四态组件,文案严格按效果图 states.png。
|
||||
|
||||
/// 空态:还没有消息
|
||||
class EmptyConversations extends StatelessWidget {
|
||||
/// 「去找同事」按钮点击(跳到通讯录 Tab)
|
||||
final VoidCallback? onFindContacts;
|
||||
|
||||
const EmptyConversations({super.key, this.onFindContacts});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.chat_bubble_outline, size: 64, color: AppColors.divider),
|
||||
const SizedBox(height: AppGap.x6),
|
||||
const Text('还没有消息', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: AppGap.x2),
|
||||
const Text('去通讯录找同事聊聊吧', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: AppGap.x6),
|
||||
FilledButton(
|
||||
onPressed: onFindContacts,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x6, vertical: AppGap.x3),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('去找同事', style: TextStyle(fontSize: AppFont.body)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载中
|
||||
class LoadingConversations extends StatelessWidget {
|
||||
const LoadingConversations({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(strokeWidth: 3, color: AppColors.primary),
|
||||
),
|
||||
SizedBox(height: AppGap.x6),
|
||||
Text('正在加载', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
|
||||
SizedBox(height: AppGap.x2),
|
||||
Text('消息马上就好,请稍等', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载失败
|
||||
class ErrorConversations extends StatelessWidget {
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorConversations({super.key, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: AppColors.divider),
|
||||
const SizedBox(height: AppGap.x6),
|
||||
const Text('消息没加载出来', style: TextStyle(fontSize: AppFont.body, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: AppGap.x2),
|
||||
const Text('检查一下网络,再点重试', style: TextStyle(fontSize: AppFont.sub, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: AppGap.x6),
|
||||
OutlinedButton(
|
||||
onPressed: onRetry,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.primary),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x6, vertical: AppGap.x3),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('重试', style: TextStyle(fontSize: AppFont.body)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 断网时顶部的深色横幅
|
||||
class OfflineBanner extends StatelessWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppColors.bannerBg,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppGap.x4, vertical: AppGap.x2),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 16, color: Colors.white),
|
||||
SizedBox(width: AppGap.x2),
|
||||
Text('当前网络不可用,请检查网络连接', style: TextStyle(fontSize: AppFont.sub, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 断网时列表底部的灰字提示
|
||||
class OfflineFooter extends StatelessWidget {
|
||||
const OfflineFooter({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: AppGap.x4),
|
||||
child: Center(
|
||||
child: Text('老消息还能看,新消息联网后自动收到', style: TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user