import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../core/services/voice_recognition_service.dart'; import '../providers/voice_recognition_provider.dart'; /// AI語音輸入按鈕 /// /// 提供語音識別功能,支援: /// - 長按開始錄音,鬆開停止錄音 /// - 實時音量動畫效果 /// - 錯誤狀態提示 /// - 多語言支援 class VoiceInputButton extends ConsumerStatefulWidget { /// 語音識別結果回調 final Function(String text) onResult; /// 語音識別錯誤回調 final Function(String error)? onError; /// 語言ID (zh-TW, zh-CN, en-US, en-GB) final String languageId; /// 按鈕大小 final double size; /// 是否啟用部分結果 final bool enablePartialResults; /// 監聽超時時間 final Duration timeout; const VoiceInputButton({ super.key, required this.onResult, this.onError, this.languageId = 'zh-TW', this.size = 56.0, this.enablePartialResults = true, this.timeout = const Duration(seconds: 30), }); @override ConsumerState createState() => _VoiceInputButtonState(); } class _VoiceInputButtonState extends ConsumerState with TickerProviderStateMixin { late AnimationController _pulseController; late AnimationController _scaleController; late Animation _pulseAnimation; late Animation _scaleAnimation; @override void initState() { super.initState(); // 脈搏動畫控制器 _pulseController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); _pulseAnimation = Tween( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _pulseController, curve: Curves.easeInOut, )); // 縮放動畫控制器 _scaleController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, ); _scaleAnimation = Tween( begin: 1.0, end: 1.1, ).animate(CurvedAnimation( parent: _scaleController, curve: Curves.easeInOut, )); } @override void dispose() { _pulseController.dispose(); _scaleController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // 監聽語音識別狀態 final voiceState = ref.watch(voiceRecognitionStateProvider); final voiceController = ref.watch(voiceRecognitionControllerProvider.notifier); final controllerState = ref.watch(voiceRecognitionControllerProvider); final soundLevel = ref.watch(voiceSoundLevelProvider); // 監聽識別結果 ref.listen>( voiceRecognitionResultProvider, (previous, next) { next.whenData((result) { if (result.isFinal) { widget.onResult(result.recognizedWords); voiceController.updateLastResult(result); } }); }, ); // 監聽狀態變化 ref.listen>( voiceRecognitionStateProvider, (previous, next) { next.whenData((state) { if (state.status == VoiceRecognitionStatus.listening) { _pulseController.repeat(reverse: true); _scaleController.forward(); } else { _pulseController.stop(); _scaleController.reverse(); } if (state.hasError) { widget.onError?.call(state.errorMessage ?? '語音識別錯誤'); } }); }, ); return AnimatedBuilder( animation: Listenable.merge([_pulseAnimation, _scaleAnimation]), builder: (context, child) { return Container( width: widget.size.w, height: widget.size.w, decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( colors: [ Theme.of(context).primaryColor.withOpacity(0.3), Theme.of(context).primaryColor.withOpacity(0.1), Colors.transparent, ], stops: [ 0.0, _pulseAnimation.value * 0.8, _pulseAnimation.value, ], ), ), child: Center( child: Transform.scale( scale: _scaleAnimation.value, child: GestureDetector( onLongPressStart: (_) => _startListening(), onLongPressEnd: (_) => _stopListening(), onTap: () => _toggleListening(), child: Container( width: (widget.size * 0.8).w, height: (widget.size * 0.8).w, decoration: BoxDecoration( shape: BoxShape.circle, color: _getButtonColor(controllerState), boxShadow: [ BoxShadow( color: Theme.of(context).primaryColor.withOpacity(0.3), blurRadius: 8, spreadRadius: 2, ), ], ), child: Stack( children: [ // 主要圖標 Center( child: Icon( _getButtonIcon(controllerState), color: Colors.white, size: (widget.size * 0.4).w, ), ), // 音量指示器 if (controllerState.isListening) _buildSoundLevelIndicator(soundLevel), ], ), ), ), ), ), ); }, ); } /// 音量指示器 Widget _buildSoundLevelIndicator(AsyncValue soundLevelAsync) { return soundLevelAsync.when( data: (level) { return Positioned.fill( child: CustomPaint( painter: SoundLevelPainter( level: level, color: Colors.white.withOpacity(0.8), ), ), ); }, loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), ); } /// 開始監聽 Future _startListening() async { final controller = ref.read(voiceRecognitionControllerProvider.notifier); await controller.startListening( languageId: widget.languageId, timeout: widget.timeout, partialResults: widget.enablePartialResults, ); } /// 停止監聽 Future _stopListening() async { final controller = ref.read(voiceRecognitionControllerProvider.notifier); await controller.stopListening(); } /// 切換監聽狀態 Future _toggleListening() async { final state = ref.read(voiceRecognitionControllerProvider); if (state.isListening) { await _stopListening(); } else { await _startListening(); } } /// 獲取按鈕顏色 Color _getButtonColor(VoiceRecognitionControllerState state) { if (!state.isInitialized || !state.isAvailable) { return Colors.grey; } if (state.isListening) { return Colors.red; } if (state.isProcessing) { return Theme.of(context).primaryColor.withOpacity(0.7); } return Theme.of(context).primaryColor; } /// 獲取按鈕圖標 IconData _getButtonIcon(VoiceRecognitionControllerState state) { if (!state.isInitialized || !state.isAvailable) { return Icons.mic_off; } if (state.isListening) { return Icons.stop; } if (state.isProcessing) { return Icons.hourglass_empty; } return Icons.mic; } } /// 音量波形繪製器 class SoundLevelPainter extends CustomPainter { final double level; final Color color; SoundLevelPainter({ required this.level, required this.color, }); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = 2.0; final center = Offset(size.width / 2, size.height / 2); final radius = math.min(size.width, size.height) / 2; // 繪製音量波紋 for (int i = 1; i <= 3; i++) { final waveRadius = radius * 0.6 + (level * 0.3 * radius) * i / 3; final alpha = (1.0 - i / 3) * level; paint.color = color.withOpacity(alpha); canvas.drawCircle(center, waveRadius, paint); } } @override bool shouldRepaint(covariant SoundLevelPainter oldDelegate) { return oldDelegate.level != level || oldDelegate.color != color; } } /// 語音輸入提示對話框 class VoiceInputDialog extends ConsumerStatefulWidget { final String languageId; final Function(String text) onResult; final Function(String error)? onError; const VoiceInputDialog({ super.key, this.languageId = 'zh-TW', required this.onResult, this.onError, }); @override ConsumerState createState() => _VoiceInputDialogState(); } class _VoiceInputDialogState extends ConsumerState { String _currentText = ''; @override Widget build(BuildContext context) { // 監聽識別結果 ref.listen>( voiceRecognitionResultProvider, (previous, next) { next.whenData((result) { setState(() { _currentText = result.recognizedWords; }); if (result.isFinal) { Navigator.of(context).pop(); widget.onResult(result.recognizedWords); } }); }, ); return AlertDialog( title: Text( '語音輸入', style: TextStyle(fontSize: 18.sp), textAlign: TextAlign.center, ), content: Container( width: 280.w, height: 200.h, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // 語音輸入按鈕 VoiceInputButton( size: 80, languageId: widget.languageId, onResult: (text) {}, onError: widget.onError, ), SizedBox(height: 20.h), // 識別文字顯示 Container( width: double.infinity, height: 80.h, padding: EdgeInsets.all(12.w), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8.r), ), child: Center( child: Text( _currentText.isEmpty ? '請開始說話...' : _currentText, style: TextStyle( fontSize: 14.sp, color: _currentText.isEmpty ? Colors.grey : Colors.black87, ), textAlign: TextAlign.center, maxLines: 3, overflow: TextOverflow.ellipsis, ), ), ), ], ), ), actions: [ TextButton( onPressed: () { final controller = ref.read(voiceRecognitionControllerProvider.notifier); controller.cancel(); Navigator.of(context).pop(); }, child: Text( '取消', style: TextStyle(fontSize: 14.sp), ), ), TextButton( onPressed: () { Navigator.of(context).pop(); widget.onResult(_currentText); }, child: Text( '確定', style: TextStyle(fontSize: 14.sp), ), ), ], ); } }