refactor: implement intensity decay for voice input and enhance microphone button UI
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user