Files
social-app/apps/lib/shared/widgets/message_composer.dart
T

262 lines
8.4 KiB
Dart
Raw Normal View History

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 messageComposerShellKey = ValueKey('message_composer_shell');
const messageComposerInnerKey = ValueKey('message_composer_inner');
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<LongPressMoveUpdateDetails> 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 KeyedSubtree(
key: messageComposerContainerKey,
child: Container(
key: messageComposerShellKey,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.homeComposerShell,
borderRadius: BorderRadius.circular(AppRadius.xxl),
border: Border.all(color: AppColors.homeComposerBorder),
boxShadow: const [
BoxShadow(
color: AppColors.slate200,
blurRadius: AppRadius.lg,
offset: Offset(AppSpacing.none, AppSpacing.sm),
),
BoxShadow(
color: AppColors.white,
blurRadius: AppRadius.md,
offset: Offset(AppSpacing.none, -AppSpacing.xs),
),
],
),
child: KeyedSubtree(
key: messageComposerInnerKey,
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>(
() => 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(
recordingText,
style: const TextStyle(color: AppColors.slate700),
),
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;
}
}