Files

278 lines
9.1 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../core/l10n/l10n.dart';
import '../../core/theme/design_tokens.dart';
import 'app_loading_indicator.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) {
final colorScheme = Theme.of(context).colorScheme;
return KeyedSubtree(
key: messageComposerContainerKey,
child: Container(
key: messageComposerShellKey,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.xxl),
border: Border.all(color: colorScheme.outlineVariant),
boxShadow: [
BoxShadow(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 1),
blurRadius: AppRadius.lg,
offset: const Offset(AppSpacing.none, AppSpacing.sm),
),
BoxShadow(
color: colorScheme.surface,
blurRadius: AppRadius.md,
offset: const 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: colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(child: _buildCenterArea(colorScheme)),
const SizedBox(width: AppSpacing.sm),
IconButton(
key: messageComposerRightButtonKey,
visualDensity: VisualDensity.compact,
onPressed: onTapRightAction,
icon: _isTranscribing
? AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: AppSpacing.lg,
strokeWidth: AppSpacing.xs / 2,
color: colorScheme.primary,
trackColor: colorScheme.primaryContainer,
)
: 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(
key: messageComposerHoldAreaKey,
behavior: HitTestBehavior.opaque,
gestures: {
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
duration: const Duration(milliseconds: _holdActivateDurationMs),
),
(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) {
final l10n = L10n.current;
final resolvedRecordingText =
recordingText ?? l10n.homeRecordingReleaseSend;
final resolvedRecordingHintText =
recordingHintText ?? l10n.homeRecordingHintReleaseSend;
final resolvedTranscribingText = transcribingText ?? l10n.homeTranscribing;
final resolvedHoldToSpeakText = holdToSpeakText ?? l10n.homeHoldToSpeakText;
if (_isRecording) {
if (!showRecordingInlineFeedback) {
return Align(
alignment: Alignment.center,
child: Text(
resolvedRecordingText,
style: TextStyle(color: colorScheme.onSurface),
),
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
recordingAnimation,
const SizedBox(height: AppSpacing.xs),
Text(
resolvedRecordingText,
style: TextStyle(color: colorScheme.onSurface),
),
const SizedBox(height: AppSpacing.xs),
Text(
resolvedRecordingHintText,
key: messageComposerRecordingHintKey,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
);
}
if (_isTranscribing) {
return Align(
alignment: Alignment.center,
child: Text(
resolvedTranscribingText,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
);
}
return Align(
alignment: Alignment.center,
child: Text(
resolvedHoldToSpeakText,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
);
}
IconData _resolveRightIcon() {
if (isWaitingAgent) {
return LucideIcons.square;
}
if (hasMessage) {
return LucideIcons.send;
}
return _isHoldMode ? LucideIcons.keyboard : LucideIcons.mic;
}
Color _resolveRightIconColor(ColorScheme colorScheme) {
if (isWaitingAgent || hasMessage) {
return colorScheme.primary;
}
return colorScheme.onSurfaceVariant;
}
}