250 lines
7.2 KiB
Dart
250 lines
7.2 KiB
Dart
|
|
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;
|
||
|
|
}
|
||
|
|
}
|