refactor: implement intensity decay for voice input and enhance microphone button UI

This commit is contained in:
cogwheel0
2025-08-25 21:53:41 +05:30
parent ac21ec6493
commit efefdffb04
3 changed files with 171 additions and 51 deletions

View File

@@ -27,6 +27,8 @@ class VoiceInputService {
StreamController<int>? _intensityController; StreamController<int>? _intensityController;
Stream<int> get intensityStream => Stream<int> get intensityStream =>
_intensityController?.stream ?? const Stream<int>.empty(); _intensityController?.stream ?? const Stream<int>.empty();
int _lastIntensity = 0;
Timer? _intensityDecayTimer;
/// Public stream of partial/final transcript strings and special audio tokens. /// Public stream of partial/final transcript strings and special audio tokens.
Stream<String> get textStream => Stream<String> get textStream =>
@@ -166,6 +168,20 @@ class VoiceInputService {
_currentText = ''; _currentText = '';
_isListening = true; _isListening = true;
_intensityController = StreamController<int>.broadcast(); _intensityController = StreamController<int>.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 // Check if speech recognition is available before trying to use it
if (_localSttAvailable) { if (_localSttAvailable) {
@@ -195,8 +211,16 @@ class VoiceInputService {
// Listen for results and state changes; keep subscriptions so we can cancel later // Listen for results and state changes; keep subscriptions so we can cancel later
_sttResultSub = _speech.onResultChanged.listen((SttRecognition result) { _sttResultSub = _speech.onResultChanged.listen((SttRecognition result) {
if (!_isListening) return; if (!_isListening) return;
final prevLen = _currentText.length;
_currentText = result.text; _currentText = result.text;
_textStreamController?.add(_currentText); _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) { if (result.isFinal) {
_stopListening(); _stopListening();
} }
@@ -253,6 +277,9 @@ class VoiceInputService {
_autoStopTimer = null; _autoStopTimer = null;
_ampSub?.cancel(); _ampSub?.cancel();
_ampSub = null; _ampSub = null;
_intensityDecayTimer?.cancel();
_intensityDecayTimer = null;
_lastIntensity = 0;
if (_currentText.isNotEmpty) { if (_currentText.isNotEmpty) {
_textStreamController?.add(_currentText); _textStreamController?.add(_currentText);

View File

@@ -869,6 +869,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
padding: const EdgeInsets.all(Spacing.lg), padding: const EdgeInsets.all(Spacing.lg),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Minimal, clean empty state // Minimal, clean empty state
Container( Container(
@@ -907,6 +908,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: context.conduitTheme.textPrimary, color: context.conduitTheme.textPrimary,
), ),
textAlign: TextAlign.center,
).animate().fadeIn(delay: const Duration(milliseconds: 150)), ).animate().fadeIn(delay: const Duration(milliseconds: 150)),
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
@@ -917,6 +919,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: context.conduitTheme.textSecondary, color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
textAlign: TextAlign.center,
).animate().fadeIn(delay: const Duration(milliseconds: 300)), ).animate().fadeIn(delay: const Duration(milliseconds: 300)),
], ],
), ),
@@ -1044,42 +1047,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Transform.translate( Transform.translate(
offset: const Offset(-6, 0), offset: const Offset(0, 0),
child: Center( child: SizedBox(
height: 28,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [ 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( Flexible(
child: Text( child: Text(
_formatModelDisplayName(selectedModel.name), _formatModelDisplayName(selectedModel.name),

View File

@@ -39,6 +39,116 @@ class ModernChatInput extends ConsumerStatefulWidget {
ConsumerState<ModernChatInput> createState() => _ModernChatInputState(); ConsumerState<ModernChatInput> 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<double> 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<ModernChatInput> class _ModernChatInputState extends ConsumerState<ModernChatInput>
with TickerProviderStateMixin { with TickerProviderStateMixin {
final TextEditingController _controller = TextEditingController(); final TextEditingController _controller = TextEditingController();
@@ -297,11 +407,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
] else ...[ ] else ...[
// When expanded, the left padding was reduced to move the plus button. SizedBox(width: Spacing.xs),
// Add back spacing so the text field aligns comfortably from the edge.
SizedBox(
width: Spacing.inputPadding - Spacing.xs,
),
], ],
// Text input expands to fill // Text input expands to fill
Expanded( Expanded(
@@ -328,8 +434,19 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context.conduitTheme.inputText, context.conduitTheme.inputText,
style: AppTypography.chatMessageStyle style: AppTypography.chatMessageStyle
.copyWith( .copyWith(
color: color: _isRecording
context.conduitTheme.inputText, ? context
.conduitTheme
.inputPlaceholder
: context
.conduitTheme
.inputText,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
fontWeight: _isRecording
? FontWeight.w500
: FontWeight.w400,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: AppLocalizations.of( hintText: AppLocalizations.of(
@@ -404,7 +521,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
children: [ children: [
_buildRoundButton( _buildRoundButton(
icon: Icons.add, icon: Icons.add,
onTap: widget.enabled onTap: widget.enabled && !_isRecording
? _showAttachmentOptions ? _showAttachmentOptions
: null, : null,
tooltip: AppLocalizations.of( tooltip: AppLocalizations.of(
@@ -428,7 +545,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context, context,
)!.web, )!.web,
isActive: webSearchEnabled, isActive: webSearchEnabled,
onTap: widget.enabled onTap:
widget.enabled &&
!_isRecording
? () { ? () {
ref ref
.read( .read(
@@ -453,7 +572,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context, context,
)!.imageGen, )!.imageGen,
isActive: imageGenEnabled, isActive: imageGenEnabled,
onTap: widget.enabled onTap:
widget.enabled &&
!_isRecording
? () { ? () {
ref ref
.read( .read(
@@ -470,7 +591,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
_buildRoundButton( _buildRoundButton(
icon: Icons.more_horiz, icon: Icons.more_horiz,
onTap: widget.enabled onTap:
widget.enabled && !_isRecording
? _showUnifiedToolsModal ? _showUnifiedToolsModal
: null, : null,
tooltip: AppLocalizations.of( tooltip: AppLocalizations.of(
@@ -539,11 +661,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
) / ) /
200) 200)
: 1.0, : 1.0,
child: _buildRoundButton( child: _MicButton(
icon: Platform.isIOS isRecording: _isRecording,
? CupertinoIcons intensity: _intensity,
.mic_fill
: Icons.mic,
onTap: onTap:
(widget.enabled && (widget.enabled &&
voiceAvailable) voiceAvailable)
@@ -553,7 +673,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
AppLocalizations.of( AppLocalizations.of(
context, context,
)!.voiceInput, )!.voiceInput,
isActive: _isRecording,
), ),
), ),
], ],
@@ -842,7 +961,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
color: context.conduitTheme.cardBackground, color: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.xl), borderRadius: BorderRadius.circular(AppBorderRadius.xl),
// No elevation to match modal chips // No elevation to match modal chips
boxShadow: null, boxShadow: ConduitShadows.button,
), ),
child: Center( child: Center(
child: Text( child: Text(