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;
|
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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user