429 lines
12 KiB
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |