diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart index 035fe89..dbee34e 100644 --- a/lib/features/chat/services/voice_input_service.dart +++ b/lib/features/chat/services/voice_input_service.dart @@ -27,6 +27,8 @@ class VoiceInputService { StreamController? _intensityController; Stream get intensityStream => _intensityController?.stream ?? const Stream.empty(); + int _lastIntensity = 0; + Timer? _intensityDecayTimer; /// Public stream of partial/final transcript strings and special audio tokens. Stream get textStream => @@ -166,6 +168,20 @@ class VoiceInputService { _currentText = ''; _isListening = true; _intensityController = StreamController.broadcast(); + _lastIntensity = 0; + + // Begin a gentle decay timer so the UI level bars fall when silent + _intensityDecayTimer?.cancel(); + _intensityDecayTimer = Timer.periodic(const Duration(milliseconds: 120), ( + t, + ) { + if (!_isListening) return; + if (_lastIntensity <= 0) return; + _lastIntensity = (_lastIntensity - 1).clamp(0, 10); + try { + _intensityController?.add(_lastIntensity); + } catch (_) {} + }); // Check if speech recognition is available before trying to use it if (_localSttAvailable) { @@ -195,8 +211,16 @@ class VoiceInputService { // Listen for results and state changes; keep subscriptions so we can cancel later _sttResultSub = _speech.onResultChanged.listen((SttRecognition result) { if (!_isListening) return; + final prevLen = _currentText.length; _currentText = result.text; _textStreamController?.add(_currentText); + // Map number of new characters to a rough 0..10 intensity + final delta = (_currentText.length - prevLen).clamp(0, 50); + final mapped = (delta / 5.0).ceil(); // 0 chars -> 0, 1-5 -> 1, ... + _lastIntensity = mapped.clamp(0, 10); + try { + _intensityController?.add(_lastIntensity); + } catch (_) {} if (result.isFinal) { _stopListening(); } @@ -253,6 +277,9 @@ class VoiceInputService { _autoStopTimer = null; _ampSub?.cancel(); _ampSub = null; + _intensityDecayTimer?.cancel(); + _intensityDecayTimer = null; + _lastIntensity = 0; if (_currentText.isNotEmpty) { _textStreamController?.add(_currentText); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index a03ebfd..3b60983 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -869,6 +869,7 @@ class _ChatPageState extends ConsumerState { padding: const EdgeInsets.all(Spacing.lg), child: Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ // Minimal, clean empty state Container( @@ -907,6 +908,7 @@ class _ChatPageState extends ConsumerState { fontWeight: FontWeight.w600, color: context.conduitTheme.textPrimary, ), + textAlign: TextAlign.center, ).animate().fadeIn(delay: const Duration(milliseconds: 150)), const SizedBox(height: Spacing.sm), @@ -917,6 +919,7 @@ class _ChatPageState extends ConsumerState { color: context.conduitTheme.textSecondary, fontWeight: FontWeight.w400, ), + textAlign: TextAlign.center, ).animate().fadeIn(delay: const Duration(milliseconds: 300)), ], ), @@ -1044,42 +1047,13 @@ class _ChatPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Transform.translate( - offset: const Offset(-6, 0), - child: Center( + offset: const Offset(0, 0), + child: SizedBox( + height: 28, child: Row( + mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - Opacity( - opacity: 0.0, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context - .conduitTheme - .surfaceBackground - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular( - AppBorderRadius.badge, - ), - border: Border.all( - color: - context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: Icon( - Platform.isIOS - ? CupertinoIcons.chevron_down - : Icons.keyboard_arrow_down, - size: IconSize.small, - ), - ), - ), - const SizedBox(width: Spacing.xs), Flexible( child: Text( _formatModelDisplayName(selectedModel.name), diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index e190cb2..7a01073 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -39,6 +39,116 @@ class ModernChatInput extends ConsumerStatefulWidget { ConsumerState createState() => _ModernChatInputState(); } +class _MicButton extends StatelessWidget { + final bool isRecording; + final int intensity; // 0..10 + final VoidCallback? onTap; + final String tooltip; + + const _MicButton({ + required this.isRecording, + required this.intensity, + required this.onTap, + required this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final Color borderColor = isRecording + ? context.conduitTheme.buttonPrimary + : context.conduitTheme.cardBorder; + final Color bgColor = isRecording + ? context.conduitTheme.buttonPrimary.withValues( + alpha: Alpha.buttonHover, + ) + : context.conduitTheme.cardBackground; + final Color iconColor = isRecording + ? context.conduitTheme.textPrimary + : context.conduitTheme.textPrimary.withValues(alpha: Alpha.strong); + + return Tooltip( + message: tooltip, + child: Material( + color: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.round), + side: BorderSide(color: borderColor, width: BorderWidth.regular), + ), + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.round), + onTap: onTap == null + ? null + : () { + HapticFeedback.selectionClick(); + onTap!(); + }, + child: Container( + width: TouchTarget.comfortable, + height: TouchTarget.comfortable, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(AppBorderRadius.round), + boxShadow: ConduitShadows.button, + ), + child: Center( + child: isRecording + ? _WaveformBars(intensity: intensity, color: iconColor) + : Icon( + Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic, + size: IconSize.medium, + color: iconColor, + ), + ), + ), + ), + ), + ); + } +} + +class _WaveformBars extends StatelessWidget { + final int intensity; // 0..10 + final Color color; + + const _WaveformBars({required this.intensity, required this.color}); + + @override + Widget build(BuildContext context) { + // 5 bars with varying base heights; scale with intensity + final double unit = (intensity.clamp(0, 10)) / 10.0; // 0..1 + final List factors = [0.4, 0.7, 1.0, 0.7, 0.4]; + final double maxHeight = IconSize.medium; // ~24px + // Keep bars within the available width to avoid RenderFlex overflow + final double width = 14.0; // tighter than 16 to accommodate padding + final double barWidth = 2.0; + final double gap = 1.0; + return SizedBox( + width: width, + height: maxHeight, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(5, (i) { + final double h = (maxHeight * (factors[i] * (0.3 + 0.7 * unit))) + .clamp(4.0, maxHeight); + return Padding( + padding: EdgeInsets.only(left: i == 0 ? 0.0 : gap), + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: barWidth, + height: h, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2.0), + ), + ), + ); + }), + ), + ); + } +} + class _ModernChatInputState extends ConsumerState with TickerProviderStateMixin { final TextEditingController _controller = TextEditingController(); @@ -297,11 +407,7 @@ class _ModernChatInputState extends ConsumerState ), const SizedBox(width: Spacing.xs), ] else ...[ - // When expanded, the left padding was reduced to move the plus button. - // Add back spacing so the text field aligns comfortably from the edge. - SizedBox( - width: Spacing.inputPadding - Spacing.xs, - ), + SizedBox(width: Spacing.xs), ], // Text input expands to fill Expanded( @@ -328,8 +434,19 @@ class _ModernChatInputState extends ConsumerState context.conduitTheme.inputText, style: AppTypography.chatMessageStyle .copyWith( - color: - context.conduitTheme.inputText, + color: _isRecording + ? context + .conduitTheme + .inputPlaceholder + : context + .conduitTheme + .inputText, + fontStyle: _isRecording + ? FontStyle.italic + : FontStyle.normal, + fontWeight: _isRecording + ? FontWeight.w500 + : FontWeight.w400, ), decoration: InputDecoration( hintText: AppLocalizations.of( @@ -404,7 +521,7 @@ class _ModernChatInputState extends ConsumerState children: [ _buildRoundButton( icon: Icons.add, - onTap: widget.enabled + onTap: widget.enabled && !_isRecording ? _showAttachmentOptions : null, tooltip: AppLocalizations.of( @@ -428,7 +545,9 @@ class _ModernChatInputState extends ConsumerState context, )!.web, isActive: webSearchEnabled, - onTap: widget.enabled + onTap: + widget.enabled && + !_isRecording ? () { ref .read( @@ -453,7 +572,9 @@ class _ModernChatInputState extends ConsumerState context, )!.imageGen, isActive: imageGenEnabled, - onTap: widget.enabled + onTap: + widget.enabled && + !_isRecording ? () { ref .read( @@ -470,7 +591,8 @@ class _ModernChatInputState extends ConsumerState const SizedBox(width: Spacing.xs), _buildRoundButton( icon: Icons.more_horiz, - onTap: widget.enabled + onTap: + widget.enabled && !_isRecording ? _showUnifiedToolsModal : null, tooltip: AppLocalizations.of( @@ -539,11 +661,9 @@ class _ModernChatInputState extends ConsumerState ) / 200) : 1.0, - child: _buildRoundButton( - icon: Platform.isIOS - ? CupertinoIcons - .mic_fill - : Icons.mic, + child: _MicButton( + isRecording: _isRecording, + intensity: _intensity, onTap: (widget.enabled && voiceAvailable) @@ -553,7 +673,6 @@ class _ModernChatInputState extends ConsumerState AppLocalizations.of( context, )!.voiceInput, - isActive: _isRecording, ), ), ], @@ -842,7 +961,7 @@ class _ModernChatInputState extends ConsumerState color: context.conduitTheme.cardBackground, borderRadius: BorderRadius.circular(AppBorderRadius.xl), // No elevation to match modal chips - boxShadow: null, + boxShadow: ConduitShadows.button, ), child: Center( child: Text(