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

271 lines
8.7 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) {
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 AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: AppSpacing.lg,
strokeWidth: AppSpacing.xs / 2,
color: AppColors.blue600,
trackColor: AppColors.blue100,
)
: 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() {
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: const TextStyle(color: AppColors.slate700),
),
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
recordingAnimation,
const SizedBox(height: AppSpacing.xs),
Text(
resolvedRecordingText,
style: const TextStyle(color: AppColors.slate700),
),
const SizedBox(height: AppSpacing.xs),
Text(
resolvedRecordingHintText,
key: messageComposerRecordingHintKey,
style: const TextStyle(color: AppColors.slate500),
),
],
);
}
if (_isTranscribing) {
return Align(
alignment: Alignment.center,
child: Text(
resolvedTranscribingText,
style: const TextStyle(color: AppColors.slate500),
),
);
}
return Align(
alignment: Alignment.center,
child: Text(
resolvedHoldToSpeakText,
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;
}
}