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;
Stream<int> get intensityStream =>
_intensityController?.stream ?? const Stream<int>.empty();
int _lastIntensity = 0;
Timer? _intensityDecayTimer;
/// Public stream of partial/final transcript strings and special audio tokens.
Stream<String> get textStream =>
@@ -166,6 +168,20 @@ class VoiceInputService {
_currentText = '';
_isListening = true;
_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
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);

View File

@@ -869,6 +869,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<ChatPage> {
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<ChatPage> {
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<ChatPage> {
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),

View File

@@ -39,6 +39,116 @@ class ModernChatInput extends ConsumerStatefulWidget {
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>
with TickerProviderStateMixin {
final TextEditingController _controller = TextEditingController();
@@ -297,11 +407,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
),
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<ModernChatInput>
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<ModernChatInput>
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<ModernChatInput>
context,
)!.web,
isActive: webSearchEnabled,
onTap: widget.enabled
onTap:
widget.enabled &&
!_isRecording
? () {
ref
.read(
@@ -453,7 +572,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context,
)!.imageGen,
isActive: imageGenEnabled,
onTap: widget.enabled
onTap:
widget.enabled &&
!_isRecording
? () {
ref
.read(
@@ -470,7 +591,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
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<ModernChatInput>
) /
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<ModernChatInput>
AppLocalizations.of(
context,
)!.voiceInput,
isActive: _isRecording,
),
),
],
@@ -842,7 +961,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
color: context.conduitTheme.cardBackground,
borderRadius: BorderRadius.circular(AppBorderRadius.xl),
// No elevation to match modal chips
boxShadow: null,
boxShadow: ConduitShadows.button,
),
child: Center(
child: Text(