Files
eryao/apps/lib/shared/widgets/message_composer.dart
T

250 lines
7.2 KiB
Dart
Raw Normal View History

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
import 'app_loading_indicator.dart';
enum MessageComposerMode { text, holdToSpeak }
enum MessageComposerProcess { idle, recording, transcribing }
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.onTapRightAction,
required this.onHoldToSpeakStart,
required this.onHoldToSpeakEnd,
required this.onHoldToSpeakMoveUpdate,
required this.onHoldToSpeakCancel,
required this.textInputChild,
required this.holdToSpeakText,
required this.recordingText,
required this.transcribingText,
required 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 onTapRightAction;
final VoidCallback onHoldToSpeakStart;
final VoidCallback onHoldToSpeakEnd;
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
final VoidCallback onHoldToSpeakCancel;
final Widget textInputChild;
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) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: colorScheme.outlineVariant, width: 0.5),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _buildCenterArea(colorScheme)),
const SizedBox(width: AppSpacing.sm),
_buildRightAction(colorScheme),
],
),
);
}
Widget _buildRightAction(ColorScheme colorScheme) {
if (_isTranscribing) {
return SizedBox(
width: iconSize,
height: iconSize,
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: iconSize,
strokeWidth: AppSpacing.xs / 2,
color: colorScheme.primary,
trackColor: colorScheme.primaryContainer,
),
);
}
if (_isRecording) {
return Container(
width: iconSize,
height: iconSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.error.withValues(alpha: 0.1),
),
child: Icon(
Icons.fiber_manual_record,
size: iconSize * 0.6,
color: colorScheme.error,
),
);
}
return IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: iconSize, minHeight: iconSize),
onPressed: onTapRightAction,
icon: Icon(
_resolveRightIcon(),
size: iconSize,
color: _resolveRightIconColor(colorScheme),
),
);
}
Widget _buildCenterArea(ColorScheme colorScheme) {
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'),
colorScheme: colorScheme,
)
: _buildTextInputArea(key: const ValueKey('text_mode')),
),
);
}
Widget _buildTextInputArea({required Key key}) {
return SizedBox(key: key, height: composerMinHeight, child: textInputChild);
}
Widget _buildHoldToSpeakArea({
required Key key,
required ColorScheme colorScheme,
}) {
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
duration: const Duration(milliseconds: 120),
),
(instance) {
instance.onLongPressStart = (details) => onHoldToSpeakStart();
instance.onLongPressEnd = (details) => onHoldToSpeakEnd();
instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate;
instance.onLongPressCancel = onHoldToSpeakCancel;
},
),
},
child: Container(
key: key,
width: double.infinity,
height: composerMinHeight,
alignment: Alignment.center,
child: _buildHoldToSpeakContent(colorScheme),
),
);
}
Widget _buildHoldToSpeakContent(ColorScheme colorScheme) {
if (_isRecording) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.error,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
recordingText,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
);
}
if (_isTranscribing) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
transcribingText,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.mic, size: 16, color: colorScheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.sm),
Text(
holdToSpeakText,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
);
}
IconData _resolveRightIcon() {
if (isWaitingAgent) {
return Icons.stop_rounded;
}
if (hasMessage) {
return Icons.send_rounded;
}
return _isHoldMode ? Icons.keyboard_rounded : Icons.mic_rounded;
}
Color _resolveRightIconColor(ColorScheme colorScheme) {
if (isWaitingAgent || hasMessage) {
return colorScheme.primary;
}
return colorScheme.onSurfaceVariant;
}
}