- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
305 lines
9.4 KiB
Dart
305 lines
9.4 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|