inou-mobile/lib/features/input/voice_input_widget.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,
),
),
);
}
}