1235 lines
41 KiB
Dart
1235 lines
41 KiB
Dart
// ignore_for_file: deprecated_member_use
|
|
|
|
import 'package:didvan/services/ai_rag_service.dart';
|
|
import 'package:didvan/services/ai_voice_service.dart';
|
|
import 'package:didvan/views/widgets/didvan/text.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:record/record.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'dart:ui';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
class AiVoiceChatDialog extends StatefulWidget {
|
|
const AiVoiceChatDialog({super.key});
|
|
|
|
@override
|
|
State<AiVoiceChatDialog> createState() => _AiVoiceChatDialogState();
|
|
}
|
|
|
|
class _AiVoiceChatDialogState extends State<AiVoiceChatDialog>
|
|
with TickerProviderStateMixin {
|
|
bool _isRecording = false;
|
|
bool _isPreparing = false;
|
|
bool _isProcessing = false;
|
|
bool _isAiSpeaking = false;
|
|
String _statusText = 'برای شروع مکالمه، دکمه میکروفون را نگه دارید';
|
|
String? _currentAiResponse;
|
|
|
|
late AnimationController _waveController;
|
|
late AnimationController _pulseController;
|
|
late AnimationController _glowController;
|
|
late AnimationController _particleController;
|
|
late AnimationController _preparingController;
|
|
|
|
final AudioRecorder _audioRecorder = AudioRecorder();
|
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
|
String? _recordingPath;
|
|
|
|
final List<double> _audioWaveHeights = List.generate(40, (_) => 0.3);
|
|
int _currentWaveIndex = 0;
|
|
|
|
final List<String> _thinkingMessages = [
|
|
'در حال فکر کردن...',
|
|
'در حال بررسی اطلاعات...',
|
|
'در حال تحلیل سوال شما...',
|
|
'در حال جستجو در دانش...',
|
|
'در حال پردازش درخواست...',
|
|
'در حال یافتن بهترین پاسخ...',
|
|
'در حال بررسی جزئیات...',
|
|
'در حال تحلیل دادههای مرتبط...',
|
|
'در حال مقایسهی نتایج ممکن...',
|
|
'در حال جمعآوری اطلاعات موردنیاز...',
|
|
'در حال تشخیص الگوها...'
|
|
];
|
|
|
|
final List<String> _analyzingMessages = [
|
|
'در حال تجزیه و تحلیل...',
|
|
'در حال پردازش دادهها...',
|
|
'در حال بررسی محتوا...',
|
|
'در حال ترکیب اطلاعات...',
|
|
'در حال درک منظور شما...',
|
|
'در حال آمادهسازی پاسخ...',
|
|
'در حال محاسبهی بهترین گزینه...',
|
|
'در حال درک مفهوم پرسش شما...',
|
|
'در حال بهروزرسانی اطلاعات...',
|
|
'در حال استخراج پاسخ مناسب...',
|
|
'در حال هماهنگسازی با پایگاه دانش...'
|
|
];
|
|
|
|
int _currentThinkingIndex = 0;
|
|
int _currentAnalyzingIndex = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_waveController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 100),
|
|
)..addListener(() {
|
|
if (_isRecording || _isAiSpeaking) {
|
|
setState(() {
|
|
_currentWaveIndex =
|
|
(_currentWaveIndex + 1) % _audioWaveHeights.length;
|
|
for (int i = 0; i < _audioWaveHeights.length; i++) {
|
|
_audioWaveHeights[i] = 0.3 + (math.Random().nextDouble() * 0.7);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
_pulseController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1500),
|
|
)..repeat(reverse: true);
|
|
|
|
_glowController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 2000),
|
|
)..repeat(reverse: true);
|
|
|
|
_particleController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 3),
|
|
)..repeat();
|
|
|
|
_preparingController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 800),
|
|
)..repeat(reverse: true);
|
|
|
|
_audioPlayer.playerStateStream.listen((state) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isAiSpeaking = state.playing;
|
|
if (state.processingState == ProcessingState.completed) {
|
|
_isAiSpeaking = false;
|
|
_statusText = 'برای ادامه مکالمه، دکمه میکروفون را نگه دارید';
|
|
_waveController.stop();
|
|
} else if (state.playing) {
|
|
_statusText = 'دستیار در حال پاسخ دادن است...';
|
|
_waveController.repeat();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_waveController.dispose();
|
|
_pulseController.dispose();
|
|
_glowController.dispose();
|
|
_particleController.dispose();
|
|
_preparingController.dispose();
|
|
_audioRecorder.dispose();
|
|
_audioPlayer.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _startRecording() async {
|
|
try {
|
|
if (await _audioRecorder.hasPermission()) {
|
|
if (_isAiSpeaking) {
|
|
await _audioPlayer.stop();
|
|
setState(() {
|
|
_isAiSpeaking = false;
|
|
});
|
|
}
|
|
|
|
if (_currentAiResponse != null) {
|
|
setState(() {
|
|
_currentAiResponse = null;
|
|
});
|
|
}
|
|
// >>>
|
|
|
|
setState(() {
|
|
_isPreparing = true;
|
|
_statusText = '⏳ در حال آماده سازی...';
|
|
});
|
|
|
|
_preparingController.repeat(reverse: true);
|
|
|
|
final directory = await getTemporaryDirectory();
|
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
|
_recordingPath = '${directory.path}/voice_$timestamp.m4a';
|
|
|
|
await _audioRecorder.start(
|
|
const RecordConfig(
|
|
encoder: AudioEncoder.aacLc,
|
|
),
|
|
path: _recordingPath!,
|
|
);
|
|
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
|
|
if (mounted && _isPreparing) {
|
|
setState(() {
|
|
_isPreparing = false;
|
|
_isRecording = true;
|
|
_statusText = '🎙️ در حال گوش دادن...';
|
|
});
|
|
_preparingController.stop();
|
|
_waveController.repeat();
|
|
} else if (mounted) {
|
|
await _audioRecorder.stop();
|
|
_waveController.stop();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_isPreparing = false;
|
|
_isRecording = false;
|
|
_statusText = 'خطا در شروع ضبط صدا';
|
|
});
|
|
_preparingController.stop();
|
|
_waveController.stop();
|
|
// >>>
|
|
debugPrint('Error starting recording: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _stopRecording() async {
|
|
if (!_isRecording) return;
|
|
|
|
try {
|
|
setState(() {
|
|
_isRecording = false;
|
|
_isProcessing = true;
|
|
_currentThinkingIndex = 0;
|
|
_statusText = _thinkingMessages[_currentThinkingIndex];
|
|
});
|
|
|
|
_waveController.stop();
|
|
|
|
_startThinkingMessageRotation();
|
|
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
|
|
if (!mounted) return;
|
|
|
|
final path = await _audioRecorder.stop();
|
|
|
|
if (path != null) {
|
|
final response = await AiVoiceService.uploadVoice(path);
|
|
|
|
if (response.isSuccess && response.text.isNotEmpty) {
|
|
setState(() {
|
|
_currentAnalyzingIndex = 0;
|
|
_statusText = _analyzingMessages[_currentAnalyzingIndex];
|
|
});
|
|
|
|
_startAnalyzingMessageRotation();
|
|
|
|
final ragResponse = await AiRagService.sendMessage(response.text);
|
|
|
|
if (ragResponse.audioUrl != null &&
|
|
ragResponse.audioUrl!.isNotEmpty) {
|
|
setState(() {
|
|
_statusText = '🔊 دستیار در حال پاسخ دادن است...';
|
|
_isProcessing = false;
|
|
_isAiSpeaking = true;
|
|
_currentAiResponse = ragResponse.output;
|
|
});
|
|
|
|
await _audioPlayer.setUrl(ragResponse.audioUrl!);
|
|
await _audioPlayer.play();
|
|
_waveController.repeat();
|
|
} else {
|
|
setState(() {
|
|
_statusText = 'خطا در پردازش';
|
|
_isProcessing = false;
|
|
});
|
|
}
|
|
} else {
|
|
setState(() {
|
|
_statusText = 'خطا در پردازش پیام صوتی';
|
|
_isProcessing = false;
|
|
});
|
|
}
|
|
|
|
try {
|
|
await File(path).delete();
|
|
} catch (e) {
|
|
debugPrint('Error deleting temp file: $e');
|
|
}
|
|
} else {
|
|
setState(() {
|
|
_isProcessing = false;
|
|
_statusText = 'خطا در ضبط صدا';
|
|
});
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_isRecording = false;
|
|
_isProcessing = false;
|
|
_statusText = 'خطا در پردازش: ${e.toString()}';
|
|
});
|
|
_waveController.stop();
|
|
debugPrint('Error stopping recording: $e');
|
|
}
|
|
}
|
|
|
|
void _startThinkingMessageRotation() {
|
|
Future.delayed(const Duration(seconds: 10), () {
|
|
if (mounted && _isProcessing && !_isAiSpeaking) {
|
|
setState(() {
|
|
_currentThinkingIndex =
|
|
(_currentThinkingIndex + 1) % _thinkingMessages.length;
|
|
_statusText = _thinkingMessages[_currentThinkingIndex];
|
|
});
|
|
_startThinkingMessageRotation();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _startAnalyzingMessageRotation() {
|
|
Future.delayed(const Duration(seconds: 10), () {
|
|
if (mounted && _isProcessing && !_isAiSpeaking) {
|
|
setState(() {
|
|
_currentAnalyzingIndex =
|
|
(_currentAnalyzingIndex + 1) % _analyzingMessages.length;
|
|
_statusText = _analyzingMessages[_currentAnalyzingIndex];
|
|
});
|
|
_startAnalyzingMessageRotation();
|
|
}
|
|
});
|
|
}
|
|
|
|
Color _getMainColor() {
|
|
if (_isPreparing) return const Color(0xFFFFA500);
|
|
if (_isRecording) return const Color.fromARGB(255, 178, 4, 54);
|
|
if (_isAiSpeaking) return const Color(0xFF00AAFF);
|
|
if (_isProcessing) return const Color(0xFFFFAA00);
|
|
return const Color(0xFF6B7280);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 700),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
const Color(0xFF0A0E27).withOpacity(0.95),
|
|
const Color(0xFF1A1F3A).withOpacity(0.95),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(32),
|
|
border: Border.all(
|
|
color: Colors.white.withOpacity(0.1),
|
|
width: 1.5,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: _getMainColor().withOpacity(0.3),
|
|
blurRadius: 40,
|
|
spreadRadius: 0,
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(32),
|
|
child: Stack(
|
|
children: [
|
|
_buildAnimatedBackground(),
|
|
Column(
|
|
children: [
|
|
_buildHeader(),
|
|
Expanded(
|
|
child: _buildMainContent(),
|
|
),
|
|
_buildControls(),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnimatedBackground() {
|
|
return AnimatedBuilder(
|
|
animation: _particleController,
|
|
builder: (context, child) {
|
|
return CustomPaint(
|
|
painter: ParticlePainter(
|
|
animation: _particleController,
|
|
color: _getMainColor(),
|
|
),
|
|
size: Size.infinite,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.white.withOpacity(0.05),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [_getMainColor(), _getMainColor().withOpacity(0.6)],
|
|
),
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: _getMainColor().withOpacity(0.5),
|
|
blurRadius: 20,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
_isRecording
|
|
? Icons.mic_rounded
|
|
: _isAiSpeaking
|
|
? Icons.volume_up_rounded
|
|
: Icons.headset_mic_rounded,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
const Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DidvanText(
|
|
'گفتگوی صوتی',
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(height: 4),
|
|
DidvanText(
|
|
'دستیار هوشمند دیدوان',
|
|
fontSize: 12,
|
|
color: Colors.white60,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close_rounded, color: Colors.white70),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMainContent() {
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
height: 500,
|
|
child: Stack(
|
|
alignment: Alignment.topCenter,
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
child: _buildVisualization(),
|
|
),
|
|
if (_currentAiResponse != null &&
|
|
_currentAiResponse!.isNotEmpty)
|
|
Positioned(
|
|
bottom: 120,
|
|
left: 24,
|
|
right: 24,
|
|
child: _buildResponsePreview(),
|
|
)
|
|
else
|
|
Positioned(
|
|
bottom: 130,
|
|
left: 24,
|
|
right: 24,
|
|
child: _buildStatusText(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVisualization() {
|
|
return AnimatedBuilder(
|
|
animation: Listenable.merge(
|
|
[_pulseController, _glowController, _preparingController]),
|
|
builder: (context, child) {
|
|
final pulseValue = _pulseController.value;
|
|
final glowValue = _glowController.value;
|
|
final preparingValue = _preparingController.value;
|
|
|
|
return Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
if (_isPreparing) ...[
|
|
for (int i = 0; i < 4; i++)
|
|
Transform.scale(
|
|
scale: 1.0 + (preparingValue * 0.3) + (i * 0.15),
|
|
child: Container(
|
|
width: 200.0,
|
|
height: 200.0,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: const Color(0xFFFFA500)
|
|
.withOpacity(0.4 - (i * 0.1)),
|
|
width: 3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (!_isPreparing)
|
|
for (int i = 0; i < 3; i++)
|
|
Container(
|
|
width: 280 + (i * 40) + (pulseValue * 20),
|
|
height: 280 + (i * 40) + (pulseValue * 20),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: _getMainColor().withOpacity(0.1 - (i * 0.03)),
|
|
width: 2,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
width: 260 + (glowValue * 20),
|
|
height: 260 + (glowValue * 20),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
_getMainColor().withOpacity(0.3),
|
|
_getMainColor().withOpacity(0.1),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_isPreparing)
|
|
SizedBox(
|
|
width: 240,
|
|
height: 240,
|
|
child: CustomPaint(
|
|
painter: PreparingSpinnerPainter(
|
|
animation: preparingValue,
|
|
color: const Color(0xFFFFA500),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SizedBox(
|
|
width: 240,
|
|
height: 240,
|
|
child: CustomPaint(
|
|
painter: WaveVisualizerPainter(
|
|
waveHeights: _audioWaveHeights,
|
|
color: _getMainColor(),
|
|
isActive: _isRecording || _isAiSpeaking,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
width: 120,
|
|
height: 120,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
_getMainColor(),
|
|
_getMainColor().withOpacity(0.7),
|
|
],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: _getMainColor().withOpacity(0.6),
|
|
blurRadius: 30,
|
|
spreadRadius: 5,
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
_isPreparing
|
|
? Icons.settings_rounded
|
|
: _isRecording
|
|
? Icons.mic_rounded
|
|
: _isAiSpeaking
|
|
? Icons.graphic_eq_rounded
|
|
: _isProcessing
|
|
? Icons.hourglass_empty_rounded
|
|
: Icons.headphones_rounded,
|
|
color: Colors.white,
|
|
size: 50,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusText() {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 32),
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: _getMainColor().withOpacity(0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: DidvanText(
|
|
_statusText,
|
|
fontSize: 14,
|
|
color: Colors.white,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResponsePreview() {
|
|
return TweenAnimationBuilder<double>(
|
|
tween: Tween(begin: 0.0, end: 1.0),
|
|
duration: const Duration(milliseconds: 600),
|
|
curve: Curves.easeOutCubic,
|
|
builder: (context, value, child) {
|
|
return Transform.translate(
|
|
offset: Offset(0, 30 * (1 - value)),
|
|
child: Opacity(
|
|
opacity: value,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: Container(
|
|
margin: const EdgeInsets.fromLTRB(2, 8, 2, 0),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Colors.white.withOpacity(0.15),
|
|
Colors.white.withOpacity(0.08),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(24),
|
|
border: Border.all(
|
|
color: Colors.white.withOpacity(0.2),
|
|
width: 1.5,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF00AAFF).withOpacity(0.2),
|
|
blurRadius: 20,
|
|
spreadRadius: 0,
|
|
),
|
|
],
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
onTap: _showFullResponseDialog,
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: [
|
|
Color(0xFF00AAFF),
|
|
Color(0xFF0066AA),
|
|
],
|
|
),
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF00AAFF)
|
|
.withOpacity(0.4),
|
|
blurRadius: 12,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: const Icon(
|
|
Icons.chat_bubble_rounded,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Expanded(
|
|
child: DidvanText(
|
|
'پاسخ دستیار',
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.white.withOpacity(0.2),
|
|
Colors.white.withOpacity(0.1),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
DidvanText(
|
|
'مشاهده کامل',
|
|
fontSize: 11,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
SizedBox(width: 4),
|
|
Icon(
|
|
Icons.arrow_back_ios_rounded,
|
|
color: Colors.white,
|
|
size: 12,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
DidvanText(
|
|
_currentAiResponse!,
|
|
fontSize: 13.5,
|
|
color: Colors.white.withOpacity(0.95),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(
|
|
3,
|
|
(index) => Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
|
width: 4,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.6),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showFullResponseDialog() {
|
|
showDialog(
|
|
context: context,
|
|
barrierColor: Colors.black.withOpacity(0.7),
|
|
builder: (context) => Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
|
|
child: ScaleTransition(
|
|
scale: CurvedAnimation(
|
|
parent: AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 300),
|
|
)..forward(),
|
|
curve: Curves.easeOutBack,
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxHeight: 600),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
const Color(0xFF0A0E27).withOpacity(0.95),
|
|
const Color(0xFF1A1F3A).withOpacity(0.95),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(
|
|
color: Colors.white.withOpacity(0.15),
|
|
width: 1.5,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF00AAFF).withOpacity(0.3),
|
|
blurRadius: 40,
|
|
spreadRadius: 0,
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.white.withOpacity(0.08),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: [
|
|
Color(0xFF00AAFF),
|
|
Color(0xFF0066AA),
|
|
],
|
|
),
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color:
|
|
const Color(0xFF00AAFF).withOpacity(0.4),
|
|
blurRadius: 15,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: const Icon(
|
|
Icons.chat_bubble_rounded,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
const Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DidvanText(
|
|
'پاسخ کامل دستیار',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(height: 2),
|
|
DidvanText(
|
|
'دیدوان AI',
|
|
fontSize: 11,
|
|
color: Colors.white60,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.close_rounded,
|
|
color: Colors.white70,
|
|
size: 24,
|
|
),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: Colors.white.withOpacity(0.1),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: DidvanText(
|
|
_currentAiResponse ?? '',
|
|
fontSize: 14,
|
|
color: Colors.white.withOpacity(0.95),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.bottomCenter,
|
|
end: Alignment.topCenter,
|
|
colors: [
|
|
Colors.white.withOpacity(0.05),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(
|
|
text: _currentAiResponse ?? ''));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const DidvanText(
|
|
'متن کپی شد',
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
),
|
|
backgroundColor: const Color(0xFF00AAFF),
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
},
|
|
icon: const Icon(
|
|
Icons.copy_rounded,
|
|
size: 18,
|
|
color: Colors.white,
|
|
),
|
|
label: const DidvanText(
|
|
'کپی متن',
|
|
fontSize: 13,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.white.withOpacity(0.15),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 14,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(
|
|
color: Colors.white.withOpacity(0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildControls() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (_isAiSpeaking)
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 16),
|
|
child: DidvanText(
|
|
'',
|
|
fontSize: 12,
|
|
color: Colors.white70,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onLongPressStart: (_) {
|
|
if (!_isProcessing && !_isRecording && !_isPreparing) {
|
|
_startRecording();
|
|
}
|
|
},
|
|
onLongPressEnd: (_) {
|
|
if (_isRecording) {
|
|
_stopRecording();
|
|
} else if (_isPreparing) {
|
|
setState(() {
|
|
_isPreparing = false;
|
|
_statusText = 'برای شروع مکالمه، دکمه میکروفون را نگه دارید';
|
|
});
|
|
_preparingController.stop();
|
|
_waveController.stop();
|
|
}
|
|
},
|
|
// >>>
|
|
child: AnimatedBuilder(
|
|
animation: _pulseController,
|
|
builder: (context, child) {
|
|
return Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: _isPreparing
|
|
? [const Color(0xFFFFA500), const Color(0xFFFFCC00)]
|
|
: _isRecording
|
|
? [
|
|
const Color.fromARGB(255, 178, 4, 54),
|
|
const Color.fromARGB(255, 255, 200, 215)
|
|
]
|
|
: _isProcessing
|
|
? [Colors.grey.shade600, Colors.grey.shade700]
|
|
: [
|
|
const Color(0xFF0066AA),
|
|
const Color(0xFF00AAFF)
|
|
],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: _getMainColor().withOpacity(0.5),
|
|
blurRadius: 20 + (_pulseController.value * 10),
|
|
spreadRadius: 5 + (_pulseController.value * 5),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
_isPreparing || _isRecording
|
|
? Icons.stop_rounded
|
|
: Icons.mic_rounded,
|
|
color: Colors.white,
|
|
size: 36,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class WaveVisualizerPainter extends CustomPainter {
|
|
final List<double> waveHeights;
|
|
final Color color;
|
|
final bool isActive;
|
|
|
|
WaveVisualizerPainter({
|
|
required this.waveHeights,
|
|
required this.color,
|
|
required this.isActive,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
if (!isActive) return;
|
|
|
|
final paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.fill;
|
|
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.width / 2;
|
|
final barCount = waveHeights.length;
|
|
final angleStep = (2 * math.pi) / barCount;
|
|
|
|
for (int i = 0; i < barCount; i++) {
|
|
final angle = i * angleStep;
|
|
final height = waveHeights[i] * 40;
|
|
|
|
final startX = center.dx + (radius - 10) * math.cos(angle);
|
|
final startY = center.dy + (radius - 10) * math.sin(angle);
|
|
final endX = center.dx + (radius - 10 + height) * math.cos(angle);
|
|
final endY = center.dy + (radius - 10 + height) * math.sin(angle);
|
|
|
|
paint.strokeWidth = 3;
|
|
paint.strokeCap = StrokeCap.round;
|
|
paint.color = color.withOpacity(0.6 + (waveHeights[i] * 0.4));
|
|
|
|
canvas.drawLine(
|
|
Offset(startX, startY),
|
|
Offset(endX, endY),
|
|
paint,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant WaveVisualizerPainter oldDelegate) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class ParticlePainter extends CustomPainter {
|
|
final Animation<double> animation;
|
|
final Color color;
|
|
|
|
ParticlePainter({required this.animation, required this.color});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = color.withOpacity(0.1)
|
|
..style = PaintingStyle.fill;
|
|
|
|
final random = math.Random(42);
|
|
for (int i = 0; i < 30; i++) {
|
|
final x = random.nextDouble() * size.width;
|
|
final baseY = random.nextDouble() * size.height;
|
|
final y = baseY + (animation.value * 100) % size.height;
|
|
final radius = 1 + random.nextDouble() * 2;
|
|
|
|
canvas.drawCircle(Offset(x, y), radius, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant ParticlePainter oldDelegate) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class PreparingSpinnerPainter extends CustomPainter {
|
|
final double animation;
|
|
final Color color;
|
|
|
|
PreparingSpinnerPainter({required this.animation, required this.color});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 4
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final radius = size.width / 2 - 20;
|
|
const dotCount = 12;
|
|
const angleStep = (2 * math.pi) / dotCount;
|
|
|
|
for (int i = 0; i < dotCount; i++) {
|
|
final angle = (i * angleStep) + (animation * 2 * math.pi);
|
|
final opacity = (1.0 - (i / dotCount)) * 0.8;
|
|
|
|
final x = center.dx + radius * math.cos(angle);
|
|
final y = center.dy + radius * math.sin(angle);
|
|
|
|
paint.color = color.withOpacity(opacity);
|
|
|
|
canvas.drawCircle(
|
|
Offset(x, y),
|
|
3 + (animation * 2),
|
|
paint..style = PaintingStyle.fill,
|
|
);
|
|
}
|
|
|
|
for (int i = 0; i < 3; i++) {
|
|
final arcRadius = radius - (i * 25);
|
|
final startAngle = (animation * 2 * math.pi) + (i * math.pi / 3);
|
|
final sweepAngle = math.pi / 2 + (animation * math.pi / 4);
|
|
|
|
paint
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 3.0 - i
|
|
..color = color.withOpacity(0.5 - (i * 0.1));
|
|
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: arcRadius),
|
|
startAngle,
|
|
sweepAngle,
|
|
false,
|
|
paint,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant PreparingSpinnerPainter oldDelegate) {
|
|
return true;
|
|
}
|
|
}
|