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 createState() => _ChatInputBarState(); } class _ChatInputBarState extends State { 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 _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 _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 _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)), ], ), ), ); } }