547 lines
16 KiB
Dart
547 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../../services/voice_service.dart';
|
|
import '../../core/theme.dart';
|
|
|
|
/// Callback when voice input produces text
|
|
typedef VoiceInputCallback = void Function(String text);
|
|
|
|
/// Modern voice input widget with animations
|
|
///
|
|
/// Features:
|
|
/// - Animated mic button with pulse effect while listening
|
|
/// - Real-time transcript display
|
|
/// - Waveform visualization
|
|
/// - Tap to start/stop
|
|
/// - Optional continuous dictation mode
|
|
class VoiceInputWidget extends StatefulWidget {
|
|
/// Called when transcription is available
|
|
final VoiceInputCallback? onTranscript;
|
|
|
|
/// Called when final result is ready
|
|
final VoiceInputCallback? onFinalResult;
|
|
|
|
/// Enable continuous dictation mode
|
|
final bool continuousMode;
|
|
|
|
/// Language locale (e.g., 'en_US', 'de_DE')
|
|
final String locale;
|
|
|
|
/// Show transcript text
|
|
final bool showTranscript;
|
|
|
|
/// Custom mic button size
|
|
final double buttonSize;
|
|
|
|
/// Compact mode (smaller button, no label)
|
|
final bool compact;
|
|
|
|
const VoiceInputWidget({
|
|
super.key,
|
|
this.onTranscript,
|
|
this.onFinalResult,
|
|
this.continuousMode = false,
|
|
this.locale = 'en_US',
|
|
this.showTranscript = true,
|
|
this.buttonSize = 72,
|
|
this.compact = false,
|
|
});
|
|
|
|
@override
|
|
State<VoiceInputWidget> createState() => _VoiceInputWidgetState();
|
|
}
|
|
|
|
class _VoiceInputWidgetState extends State<VoiceInputWidget>
|
|
with TickerProviderStateMixin {
|
|
final VoiceService _voiceService = VoiceService();
|
|
|
|
VoiceStatus _status = VoiceStatus.idle;
|
|
String _transcript = '';
|
|
String _errorMessage = '';
|
|
double _soundLevel = 0.0;
|
|
|
|
late AnimationController _pulseController;
|
|
late AnimationController _waveController;
|
|
late Animation<double> _pulseAnimation;
|
|
|
|
StreamSubscription<VoiceStatus>? _statusSubscription;
|
|
StreamSubscription<String>? _transcriptSubscription;
|
|
StreamSubscription<double>? _soundLevelSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Pulse animation for the mic button
|
|
_pulseController = AnimationController(
|
|
duration: const Duration(milliseconds: 1000),
|
|
vsync: this,
|
|
);
|
|
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(
|
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
|
);
|
|
|
|
// Wave animation
|
|
_waveController = AnimationController(
|
|
duration: const Duration(milliseconds: 2000),
|
|
vsync: this,
|
|
);
|
|
|
|
_initVoiceService();
|
|
}
|
|
|
|
Future<void> _initVoiceService() async {
|
|
// Set up callbacks
|
|
_voiceService.onResult = (text, isFinal) {
|
|
setState(() => _transcript = text);
|
|
widget.onTranscript?.call(text);
|
|
if (isFinal) {
|
|
widget.onFinalResult?.call(text);
|
|
}
|
|
};
|
|
|
|
_voiceService.onError = (error, message) {
|
|
setState(() {
|
|
_errorMessage = message;
|
|
_status = VoiceStatus.error;
|
|
});
|
|
_stopAnimations();
|
|
|
|
// Auto-clear error after 3 seconds
|
|
Future.delayed(const Duration(seconds: 3), () {
|
|
if (mounted && _status == VoiceStatus.error) {
|
|
setState(() {
|
|
_errorMessage = '';
|
|
_status = VoiceStatus.ready;
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
// Subscribe to streams
|
|
_statusSubscription = _voiceService.statusStream.listen((status) {
|
|
setState(() => _status = status);
|
|
if (status == VoiceStatus.listening) {
|
|
_startAnimations();
|
|
} else {
|
|
_stopAnimations();
|
|
}
|
|
});
|
|
|
|
_soundLevelSubscription = _voiceService.soundLevelStream.listen((level) {
|
|
setState(() => _soundLevel = level);
|
|
});
|
|
|
|
// Initialize service
|
|
await _voiceService.initialize();
|
|
setState(() => _status = _voiceService.status);
|
|
}
|
|
|
|
void _startAnimations() {
|
|
_pulseController.repeat(reverse: true);
|
|
_waveController.repeat();
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
|
|
void _stopAnimations() {
|
|
_pulseController.stop();
|
|
_pulseController.reset();
|
|
_waveController.stop();
|
|
_waveController.reset();
|
|
}
|
|
|
|
Future<void> _toggleListening() async {
|
|
HapticFeedback.mediumImpact();
|
|
|
|
if (_voiceService.isListening) {
|
|
await _voiceService.stopListening();
|
|
} else {
|
|
setState(() {
|
|
_transcript = '';
|
|
_errorMessage = '';
|
|
});
|
|
await _voiceService.startListening(
|
|
localeId: widget.locale,
|
|
continuous: widget.continuousMode,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pulseController.dispose();
|
|
_waveController.dispose();
|
|
_statusSubscription?.cancel();
|
|
_transcriptSubscription?.cancel();
|
|
_soundLevelSubscription?.cancel();
|
|
_voiceService.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.compact) {
|
|
return _buildCompactButton();
|
|
}
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Transcript display
|
|
if (widget.showTranscript) ...[
|
|
_buildTranscriptArea(),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Waveform visualization
|
|
if (_status == VoiceStatus.listening) ...[
|
|
_buildWaveform(),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Mic button with animations
|
|
_buildMicButton(),
|
|
|
|
// Status label
|
|
const SizedBox(height: 8),
|
|
_buildStatusLabel(),
|
|
|
|
// Error message
|
|
if (_errorMessage.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
_buildErrorMessage(),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCompactButton() {
|
|
return _buildMicButton();
|
|
}
|
|
|
|
Widget _buildTranscriptArea() {
|
|
final isListening = _status == VoiceStatus.listening;
|
|
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.surfaceColor,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isListening
|
|
? AppTheme.primaryColor.withOpacity(0.5)
|
|
: Colors.transparent,
|
|
width: 2,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_transcript.isEmpty
|
|
? (isListening ? 'Listening...' : 'Tap mic to speak')
|
|
: _transcript,
|
|
style: TextStyle(
|
|
color: _transcript.isEmpty
|
|
? Colors.grey
|
|
: AppTheme.textColor,
|
|
fontSize: 16,
|
|
fontStyle: _transcript.isEmpty
|
|
? FontStyle.italic
|
|
: FontStyle.normal,
|
|
),
|
|
),
|
|
),
|
|
if (_transcript.isNotEmpty)
|
|
IconButton(
|
|
icon: const Icon(Icons.copy, size: 20),
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(text: _transcript));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Copied to clipboard'),
|
|
duration: Duration(seconds: 1),
|
|
),
|
|
);
|
|
},
|
|
color: Colors.grey,
|
|
tooltip: 'Copy',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWaveform() {
|
|
return AnimatedBuilder(
|
|
animation: _waveController,
|
|
builder: (context, child) {
|
|
return CustomPaint(
|
|
size: Size(MediaQuery.of(context).size.width - 64, 48),
|
|
painter: WaveformPainter(
|
|
soundLevel: _soundLevel,
|
|
animationValue: _waveController.value,
|
|
color: AppTheme.primaryColor,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildMicButton() {
|
|
final isListening = _status == VoiceStatus.listening;
|
|
final isProcessing = _status == VoiceStatus.processing;
|
|
final isError = _status == VoiceStatus.error;
|
|
final isInitializing = _status == VoiceStatus.initializing;
|
|
|
|
return GestureDetector(
|
|
onTap: isInitializing ? null : _toggleListening,
|
|
child: AnimatedBuilder(
|
|
animation: _pulseAnimation,
|
|
builder: (context, child) {
|
|
final scale = isListening ? _pulseAnimation.value : 1.0;
|
|
|
|
return Transform.scale(
|
|
scale: scale,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Outer pulse rings (only when listening)
|
|
if (isListening) ...[
|
|
_buildPulseRing(widget.buttonSize * 1.6, 0.1),
|
|
_buildPulseRing(widget.buttonSize * 1.3, 0.2),
|
|
],
|
|
|
|
// Main button
|
|
Container(
|
|
width: widget.buttonSize,
|
|
height: widget.buttonSize,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: isError
|
|
? [Colors.red.shade400, Colors.red.shade700]
|
|
: isListening
|
|
? [AppTheme.primaryColor, AppTheme.secondaryColor]
|
|
: [
|
|
AppTheme.surfaceColor,
|
|
AppTheme.surfaceColor.withOpacity(0.8),
|
|
],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: isListening
|
|
? AppTheme.primaryColor.withOpacity(0.4)
|
|
: Colors.black.withOpacity(0.2),
|
|
blurRadius: isListening ? 20 : 10,
|
|
spreadRadius: isListening ? 2 : 0,
|
|
),
|
|
],
|
|
),
|
|
child: Center(
|
|
child: isInitializing || isProcessing
|
|
? SizedBox(
|
|
width: widget.buttonSize * 0.4,
|
|
height: widget.buttonSize * 0.4,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 3,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
isProcessing ? AppTheme.primaryColor : Colors.white,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
isListening ? Icons.stop : Icons.mic,
|
|
color: isListening || isError
|
|
? Colors.white
|
|
: AppTheme.primaryColor,
|
|
size: widget.buttonSize * 0.45,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPulseRing(double size, double opacity) {
|
|
return AnimatedBuilder(
|
|
animation: _pulseController,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: size * _pulseAnimation.value,
|
|
height: size * _pulseAnimation.value,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: AppTheme.primaryColor.withOpacity(opacity * (1 - _pulseController.value)),
|
|
width: 2,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusLabel() {
|
|
String label;
|
|
Color color;
|
|
|
|
switch (_status) {
|
|
case VoiceStatus.idle:
|
|
case VoiceStatus.initializing:
|
|
label = 'Initializing...';
|
|
color = Colors.grey;
|
|
break;
|
|
case VoiceStatus.ready:
|
|
label = 'Tap to speak';
|
|
color = Colors.grey;
|
|
break;
|
|
case VoiceStatus.listening:
|
|
label = widget.continuousMode ? 'Listening (continuous)' : 'Listening...';
|
|
color = AppTheme.primaryColor;
|
|
break;
|
|
case VoiceStatus.processing:
|
|
label = 'Processing...';
|
|
color = AppTheme.secondaryColor;
|
|
break;
|
|
case VoiceStatus.error:
|
|
label = 'Error';
|
|
color = Colors.red;
|
|
break;
|
|
}
|
|
|
|
return AnimatedDefaultTextStyle(
|
|
duration: const Duration(milliseconds: 200),
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 14,
|
|
fontWeight: _status == VoiceStatus.listening
|
|
? FontWeight.w600
|
|
: FontWeight.normal,
|
|
),
|
|
child: Text(label),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorMessage() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
_errorMessage,
|
|
style: const TextStyle(
|
|
color: Colors.red,
|
|
fontSize: 12,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Custom painter for waveform visualization
|
|
class WaveformPainter extends CustomPainter {
|
|
final double soundLevel;
|
|
final double animationValue;
|
|
final Color color;
|
|
|
|
WaveformPainter({
|
|
required this.soundLevel,
|
|
required this.animationValue,
|
|
required this.color,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = color.withOpacity(0.8)
|
|
..strokeWidth = 3
|
|
..strokeCap = StrokeCap.round
|
|
..style = PaintingStyle.stroke;
|
|
|
|
const barCount = 32;
|
|
final barWidth = size.width / (barCount * 2);
|
|
final centerY = size.height / 2;
|
|
|
|
for (int i = 0; i < barCount; i++) {
|
|
// Create wave effect with sound level influence
|
|
final progress = i / barCount;
|
|
final wave = math.sin((progress * math.pi * 4) + (animationValue * math.pi * 2));
|
|
|
|
// Add some randomness based on sound level
|
|
final intensity = soundLevel * 0.7 + 0.3;
|
|
final height = (wave * intensity * size.height * 0.4).abs() + 2;
|
|
|
|
final x = (i * 2 + 1) * barWidth;
|
|
|
|
canvas.drawLine(
|
|
Offset(x, centerY - height / 2),
|
|
Offset(x, centerY + height / 2),
|
|
paint..color = color.withOpacity(0.4 + intensity * 0.6),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(WaveformPainter oldDelegate) {
|
|
return oldDelegate.soundLevel != soundLevel ||
|
|
oldDelegate.animationValue != animationValue;
|
|
}
|
|
}
|
|
|
|
/// Standalone mic button for inline use
|
|
class VoiceMicButton extends StatelessWidget {
|
|
final VoiceService voiceService;
|
|
final VoidCallback? onTap;
|
|
final double size;
|
|
final bool isListening;
|
|
|
|
const VoiceMicButton({
|
|
super.key,
|
|
required this.voiceService,
|
|
this.onTap,
|
|
this.size = 48,
|
|
this.isListening = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isListening ? AppTheme.primaryColor : AppTheme.surfaceColor,
|
|
boxShadow: isListening ? [
|
|
BoxShadow(
|
|
color: AppTheme.primaryColor.withOpacity(0.4),
|
|
blurRadius: 12,
|
|
spreadRadius: 2,
|
|
),
|
|
] : null,
|
|
),
|
|
child: Icon(
|
|
isListening ? Icons.stop : Icons.mic,
|
|
color: isListening ? Colors.white : AppTheme.primaryColor,
|
|
size: size * 0.5,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|