This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/widgets/voice_mic_button.dart
T
bvandeusen 48c134ce6a feat(voice): pulse mic button with live amplitude
VoiceState now carries a normalized mic amplitude (0..1) updated
from the existing onAmplitudeChanged subscription, with a 0.02
change threshold so we don't spam rebuilds.

VoiceMicButton swaps the constant-rate AnimationController for an
AnimatedScale + animated glow driven by the live amplitude. 0.1
floor keeps the button breathing on silence; 120ms ease-out smooths
between 200ms samples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:48 -04:00

103 lines
2.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import '../providers/voice_provider.dart';
/// Animated mic button that reflects the current [VoiceMode].
///
/// - idle: muted background, mic_none icon
/// - recording: red, pulses with live [amplitude] for real-time feedback
/// - transcribing: indigo with spinner
/// - playing: indigo with volume_up icon
class VoiceMicButton extends StatelessWidget {
final VoiceMode mode;
final bool voiceModeActive;
/// Live mic amplitude 0.01.0 while recording. Drives the button scale
/// and glow so the user has obvious feedback that audio is being picked
/// up. Ignored when not in [VoiceMode.recording].
final double amplitude;
final VoidCallback? onTap;
const VoiceMicButton({
super.key,
required this.mode,
required this.voiceModeActive,
this.amplitude = 0.0,
this.onTap,
});
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (mode) {
VoiceMode.recording => const Color(0xFFEF4444),
VoiceMode.transcribing || VoiceMode.playing => cs.primary,
VoiceMode.idle => cs.surfaceContainerHighest,
};
}
Widget _icon(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
mode == VoiceMode.idle ? cs.onSurfaceVariant : Colors.white;
return switch (mode) {
VoiceMode.transcribing => SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: iconColor,
),
),
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20),
_ => Icon(Icons.mic, color: iconColor, size: 20),
};
}
@override
Widget build(BuildContext context) {
final isRecording = mode == VoiceMode.recording;
final button = Material(
color: _bgColor(context),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 40,
height: 40,
child: Center(child: _icon(context)),
),
),
);
if (!isRecording) return button;
// Base pulse so silence still breathes (0.1 floor), scale + glow climb
// linearly with live amplitude.
final amp = amplitude.clamp(0.0, 1.0);
final pulse = 0.1 + amp * 0.9;
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.2 + pulse * 0.3),
blurRadius: 6 + pulse * 14,
spreadRadius: 1 + pulse * 5,
),
],
),
child: AnimatedScale(
scale: 1.0 + pulse * 0.18,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: button,
),
);
}
}