import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../core/theme/design_tokens.dart'; enum MessageComposerMode { text, holdToSpeak } enum MessageComposerProcess { idle, recording, transcribing } const messageComposerContainerKey = ValueKey('message_composer_container'); const messageComposerPlusButtonKey = ValueKey('message_composer_plus_button'); const messageComposerRightButtonKey = ValueKey('message_composer_right_button'); const messageComposerHoldAreaKey = ValueKey('message_composer_hold_area'); const messageComposerRecordingHintKey = ValueKey( 'message_composer_recording_hint', ); const _holdActivateDurationMs = 120; class MessageComposer extends StatelessWidget { const MessageComposer({ super.key, required this.mode, required this.process, required this.hasMessage, required this.isWaitingAgent, required this.iconSize, required this.composerMinHeight, required this.onTapPlus, required this.onTapRightAction, required this.onHoldToSpeakStart, required this.onHoldToSpeakEnd, required this.onHoldToSpeakMoveUpdate, required this.onHoldToSpeakCancel, required this.textInputChild, required this.recordingAnimation, this.holdToSpeakText = '按住说话', this.recordingText = '松手发送', this.transcribingText = '语音识别中...', this.recordingHintText = '松开发送,上滑取消', this.showRecordingInlineFeedback = true, }); final MessageComposerMode mode; final MessageComposerProcess process; final bool hasMessage; final bool isWaitingAgent; final double iconSize; final double composerMinHeight; final VoidCallback onTapPlus; final VoidCallback onTapRightAction; final VoidCallback onHoldToSpeakStart; final VoidCallback onHoldToSpeakEnd; final ValueChanged onHoldToSpeakMoveUpdate; final VoidCallback onHoldToSpeakCancel; final Widget textInputChild; final Widget recordingAnimation; final String holdToSpeakText; final String recordingText; final String transcribingText; final String recordingHintText; final bool showRecordingInlineFeedback; bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak; bool get _isRecording => process == MessageComposerProcess.recording; bool get _isTranscribing => process == MessageComposerProcess.transcribing; @override Widget build(BuildContext context) { return Container( key: messageComposerContainerKey, padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), decoration: BoxDecoration( color: AppColors.white, borderRadius: BorderRadius.circular(AppRadius.full), border: Border.all(color: AppColors.slate200), boxShadow: const [ BoxShadow( color: AppColors.slate200, blurRadius: AppRadius.lg, offset: Offset(AppSpacing.none, AppSpacing.xs), ), BoxShadow( color: AppColors.white, blurRadius: AppRadius.md, offset: Offset(AppSpacing.none, -AppSpacing.xs), ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IgnorePointer( ignoring: _isRecording && _isHoldMode, child: Opacity( opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1, child: IconButton( key: messageComposerPlusButtonKey, visualDensity: VisualDensity.compact, onPressed: onTapPlus, icon: Icon( LucideIcons.plus, size: iconSize, color: AppColors.slate500, ), ), ), ), const SizedBox(width: AppSpacing.sm), Expanded(child: _buildCenterArea()), const SizedBox(width: AppSpacing.sm), IconButton( key: messageComposerRightButtonKey, visualDensity: VisualDensity.compact, onPressed: onTapRightAction, icon: _isTranscribing ? const SizedBox( width: AppSpacing.lg, height: AppSpacing.lg, child: CircularProgressIndicator( strokeWidth: AppSpacing.xs / 2, color: AppColors.blue600, ), ) : Icon( _resolveRightIcon(), size: iconSize, color: _resolveRightIconColor(), ), ), ], ), ); } Widget _buildCenterArea() { return SizedBox( height: composerMinHeight, child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, child: _isHoldMode ? _buildHoldToSpeakArea(key: const ValueKey('hold_mode')) : _buildTextInputArea(key: const ValueKey('text_mode')), ), ); } Widget _buildTextInputArea({required Key key}) { return SizedBox(key: key, height: composerMinHeight, child: textInputChild); } Widget _buildHoldToSpeakArea({required Key key}) { return RawGestureDetector( key: messageComposerHoldAreaKey, behavior: HitTestBehavior.opaque, gestures: { LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => LongPressGestureRecognizer( duration: const Duration(milliseconds: _holdActivateDurationMs), ), (instance) { instance.onLongPressStart = (_) => onHoldToSpeakStart(); instance.onLongPressEnd = (_) => onHoldToSpeakEnd(); instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate; instance.onLongPressCancel = onHoldToSpeakCancel; }, ), }, child: Container( key: key, width: double.infinity, height: composerMinHeight, alignment: Alignment.center, child: _buildHoldToSpeakContent(), ), ); } Widget _buildHoldToSpeakContent() { if (_isRecording) { if (!showRecordingInlineFeedback) { return Align( alignment: Alignment.center, child: Text( recordingText, style: const TextStyle(color: AppColors.slate700), ), ); } return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ recordingAnimation, const SizedBox(height: AppSpacing.xs), Text( recordingHintText, key: messageComposerRecordingHintKey, style: const TextStyle(color: AppColors.slate500), ), ], ); } if (_isTranscribing) { return Align( alignment: Alignment.center, child: Text( transcribingText, style: const TextStyle(color: AppColors.slate500), ), ); } return Align( alignment: Alignment.center, child: Text( holdToSpeakText, style: const TextStyle(color: AppColors.slate500), ), ); } IconData _resolveRightIcon() { if (isWaitingAgent) { return LucideIcons.square; } if (hasMessage) { return LucideIcons.send; } return _isHoldMode ? LucideIcons.keyboard : LucideIcons.mic; } Color _resolveRightIconColor() { if (isWaitingAgent || hasMessage) { return AppColors.blue600; } return AppColors.slate500; } }