dramaling-app/apps/mobile/lib/shared/widgets/voice_input_button.dart

429 lines
12 KiB
Dart

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<VoiceInputButton> createState() => _VoiceInputButtonState();
}
class _VoiceInputButtonState extends ConsumerState<VoiceInputButton>
with TickerProviderStateMixin {
late AnimationController _pulseController;
late AnimationController _scaleController;
late Animation<double> _pulseAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
// 脈搏動畫控制器
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_pulseAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
// 縮放動畫控制器
_scaleController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
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<AsyncValue<VoiceRecognitionResult>>(
voiceRecognitionResultProvider,
(previous, next) {
next.whenData((result) {
if (result.isFinal) {
widget.onResult(result.recognizedWords);
voiceController.updateLastResult(result);
}
});
},
);
// 監聽狀態變化
ref.listen<AsyncValue<VoiceRecognitionState>>(
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<double> 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<void> _startListening() async {
final controller = ref.read(voiceRecognitionControllerProvider.notifier);
await controller.startListening(
languageId: widget.languageId,
timeout: widget.timeout,
partialResults: widget.enablePartialResults,
);
}
/// 停止監聽
Future<void> _stopListening() async {
final controller = ref.read(voiceRecognitionControllerProvider.notifier);
await controller.stopListening();
}
/// 切換監聽狀態
Future<void> _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<VoiceInputDialog> createState() => _VoiceInputDialogState();
}
class _VoiceInputDialogState extends ConsumerState<VoiceInputDialog> {
String _currentText = '';
@override
Widget build(BuildContext context) {
// 監聽識別結果
ref.listen<AsyncValue<VoiceRecognitionResult>>(
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),
),
),
],
);
}
}