// ignore_for_file: deprecated_member_use import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui'; import 'dart:math' as math; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:record/record.dart'; import 'package:flutter_sound/flutter_sound.dart' hide Codec; import 'package:flutter_sound_platform_interface/flutter_sound_platform_interface.dart' as fsp; import 'package:socket_io_client/socket_io_client.dart' as IO; import 'package:didvan/views/widgets/didvan/text.dart'; import 'package:path_provider/path_provider.dart'; import 'package:didvan/services/network/request.dart'; import 'package:audio_session/audio_session.dart'; class AiVoiceChatDialog extends StatefulWidget { const AiVoiceChatDialog({super.key}); @override State createState() => _AiVoiceChatDialogState(); } class _AiVoiceChatDialogState extends State with TickerProviderStateMixin { // --- Socket.IO Configuration --- final String _socketUrl = 'https://socket.houshan.ai'; IO.Socket? _socket; bool _isConnected = false; // --- Audio Configuration --- static const int inputSampleRate = 16000; static const int geminiSampleRate = 24000; // --- VAD Settings --- static const double vadThreshold = 0.05; static const double speechThreshold = 0.1; // --- روش ساده و مطمئن: آستانه ثابت --- // صدای AI از اسپیکر معمولاً RMS حدود 0.05-0.12 دارد // صدای کاربر مستقیم به میکروفون معمولاً RMS بالای 0.15 دارد static const double userInterruptThreshold = 0.25; // آستانه پایین‌تر - حساسیت بیشتر static const int ignoreInitialMs = 800; // 800ms اول نادیده گرفته شود static const int sustainedChunksRequired = 4; // 4 chunk متوالی برای تایید اینتراپت int _interruptChunkCount = 0; // شمارنده chunk‌های متوالی با صدای بالا DateTime? _aiPlaybackStartTime; // زمان شروع پخش AI static const int vadSustainMs = 150; static const int silenceTimeoutMs = 1000; Timer? _silenceTimer; bool _isSpeechActive = false; bool _ignoreAudioDuringAIPlayback = false; // --- States --- bool _isRecording = false; bool _isAiSpeaking = false; String _statusText = 'در حال اتصال...'; int _speechStartTime = 0; int _lastSpeechTime = 0; int _chunkCount = 0; // --- Controllers (UI Animations) --- late AnimationController _orbController; late AnimationController _rippleController; late AnimationController _waveController; // --- Audio --- final AudioRecorder _audioRecorder = AudioRecorder(); final FlutterSoundPlayer _audioPlayer = FlutterSoundPlayer(); StreamSubscription? _audioStreamSubscription; Timer? _vadTimer; Timer? _speakingTimer; bool _isPlayerInitialized = false; // Buffers final BytesBuilder _fullResponseAccumulator = BytesBuilder(); Timer? _playbackTriggerTimer; bool _isPlayingFromQueue = false; final List _sendBuffer = []; static const int targetChunkSize = 5462; bool _wasStoppedByUser = false; // --- Visualizer Data --- final List _audioWaveHeights = List.generate(40, (_) => 0.1); @override void initState() { super.initState(); _initAnimations(); _initAudio(); _connectSocket(); } void _initAnimations() { _orbController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), )..repeat(reverse: true); _rippleController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), )..repeat(); _waveController = AnimationController( vsync: this, duration: const Duration(milliseconds: 50), )..addListener(() { if (_isRecording || _isAiSpeaking) { setState(() { final random = math.Random(); for (int i = 0; i < _audioWaveHeights.length; i++) { double target = 0.1 + (random.nextDouble() * 0.4); if (_isAiSpeaking) target *= 1.5; if (_isSpeechActive) target *= 2.0; _audioWaveHeights[i] = _audioWaveHeights[i] + (target - _audioWaveHeights[i]) * 0.2; } }); } else { for (int i = 0; i < _audioWaveHeights.length; i++) { _audioWaveHeights[i] = _audioWaveHeights[i] * 0.9; } } }); } Future _initAudio() async { try { // تنظیم AudioSession برای پخش از طریق MEDIA نه CALL final session = await AudioSession.instance; await session.configure(AudioSessionConfiguration( // 1. حالت PlayAndRecord برای استفاده همزمان avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, // 2. تنظیمات کلیدی: // defaultToSpeaker: صدا حتما از اسپیکر بیاید (نه گوشی) // allowBluetooth: اجازه استفاده از هندزفری بلوتوث avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.allowBluetooth | AVAudioSessionCategoryOptions.defaultToSpeaker, // 3. حالت VoiceChat: این حالت پردازشگر سیگنال (DSP) موبایل را برای حذف اکو فعال می‌کند avAudioSessionMode: AVAudioSessionMode.voiceChat, androidAudioAttributes: const AndroidAudioAttributes( contentType: AndroidAudioContentType.speech, flags: AndroidAudioFlags.none, usage: AndroidAudioUsage .voiceCommunication, // در اندروید هم حالت مکالمه باشد ), androidAudioFocusGainType: AndroidAudioFocusGainType.gain, )); await _audioPlayer.openPlayer(); await _audioPlayer .setSubscriptionDuration(const Duration(milliseconds: 10)); _isPlayerInitialized = true; debugPrint('✅ Audio player initialized with VOICE CHAT + SPEAKER'); } catch (e) { debugPrint('❌ Error initializing audio player: $e'); } } // --------------------------------------------------------------------------- // Helper: Stop AI Playback (Internal Logic Only) // --------------------------------------------------------------------------- Future _stopAiPlayback() async { if (_isAiSpeaking || _isPlayingFromQueue || _fullResponseAccumulator.isNotEmpty) { debugPrint('🛑 Stopping AI Playback (Interrupted)'); _wasStoppedByUser = true; _playbackTriggerTimer?.cancel(); _fullResponseAccumulator.clear(); try { if (_audioPlayer.isPlaying) { await _audioPlayer.stopPlayer(); } } catch (e) { debugPrint("⚠️ Error stopping player: $e"); } if (mounted) { setState(() { _isPlayingFromQueue = false; _isAiSpeaking = false; if (_isRecording) { _statusText = '🎤 در حال صحبت...'; } }); } } } // --------------------------------------------------------------------------- // Socket.IO Connection // --------------------------------------------------------------------------- void _connectSocket() { try { _socket = IO.io( _socketUrl, IO.OptionBuilder() .setTransports(['websocket']) .setAuth({'token': 'Bearer ${RequestService.token}'}) .disableAutoConnect() .build(), ); _socket!.connect(); _socket!.onConnect((_) { debugPrint('✅ Socket Connected'); _socket!.emit('test', 'Hello from Flutter'); if (mounted) { setState(() { _isConnected = true; _statusText = 'آماده گوش دادن...'; }); _startRecordingStream(); } }); _socket!.onDisconnect((_) { debugPrint('❌ Socket Disconnected'); if (mounted) { setState(() { _isConnected = false; _statusText = 'اتصال قطع شد'; }); } }); _socket! .on('gemini_audio_chunk', (data) => _handleGeminiAudioChunk(data)); _socket!.on('audio', (data) => _handleGeminiAudioChunk(data)); _socket!.on('audio_chunk', (data) => _handleGeminiAudioChunk(data)); _socket!.on('gemini_audio', (data) => _handleGeminiAudioChunk(data)); _socket!.on('response', (data) => _handleGeminiAudioChunk(data)); _socket!.on('error', (error) => debugPrint('❌ Server Error: $error')); } catch (e) { debugPrint('❌ Connection Error: $e'); setState(() => _statusText = 'خطا در اتصال'); } } // --------------------------------------------------------------------------- // Recording & VAD (User Input) // --------------------------------------------------------------------------- Future _startRecordingStream() async { if (!_isConnected) return; try { if (await _audioRecorder.hasPermission()) { await _stopAiPlayback(); setState(() { _isRecording = true; _statusText = '👂 در حال گوش دادن...'; _chunkCount = 0; _speechStartTime = 0; _lastSpeechTime = 0; _isAiSpeaking = false; }); _sendBuffer.clear(); _waveController.repeat(); final stream = await _audioRecorder.startStream( const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: 16000, numChannels: 1, // روشن بودن این گزینه‌ها حیاتی است echoCancel: true, noiseSuppress: true, autoGain: false, ), ); _audioStreamSubscription = stream.listen((data) { _processAudioChunk(data); }); debugPrint('🎙️ Recording started at 16000Hz'); } } catch (e) { debugPrint('Start Recording Error: $e'); setState(() { _isRecording = false; _statusText = 'خطا در دسترسی به میکروفون'; }); } } void _flushAiBuffers({bool resetStatus = true}) { _playbackTriggerTimer?.cancel(); _fullResponseAccumulator.clear(); _isPlayingFromQueue = false; _isAiSpeaking = false; _ignoreAudioDuringAIPlayback = false; _aiPlaybackStartTime = null; // ریست زمان پخش _interruptChunkCount = 0; // ریست شمارنده try { if (_audioPlayer.isPlaying) { _audioPlayer.stopPlayer().catchError((e) { debugPrint('⚠️ Error stopping: $e'); }); } } catch (_) {} if (resetStatus && mounted) { setState(() { _statusText = _isRecording ? '🎤 در حال صحبت...' : 'آماده'; }); } } void _processAudioChunk(Uint8List chunkData) { if (!_isRecording || _socket == null) return; // ۱. محاسبه شدت صدا (RMS) double sumSquares = 0; int sampleCount = 0; for (int i = 0; i < chunkData.length; i += 2) { if (i + 1 < chunkData.length) { int sample = chunkData[i] | (chunkData[i + 1] << 8); if (sample > 32767) sample -= 65536; double floatSample = sample / 32768.0; sumSquares += floatSample * floatSample; sampleCount++; } } final rms = sampleCount > 0 ? math.sqrt(sumSquares / sampleCount) : 0; // ۲. بررسی اینتراپت - روش ساده و مطمئن if (_ignoreAudioDuringAIPlayback) { if (_aiPlaybackStartTime != null) { final elapsed = DateTime.now().difference(_aiPlaybackStartTime!).inMilliseconds; // در 500ms اول، همه چیز را نادیده بگیر (زمان برای استقرار صدا) if (elapsed < ignoreInitialMs) { return; } // اگر صدا از آستانه بالاتر بود if (rms > userInterruptThreshold) { _interruptChunkCount++; // اگر چند chunk متوالی صدای بالا داشتیم = کاربر واقعاً صحبت می‌کند if (_interruptChunkCount >= sustainedChunksRequired) { debugPrint( '🧯 User speaking detected! RMS: ${rms.toStringAsFixed(3)}, Chunks: $_interruptChunkCount - Interrupting AI'); _wasStoppedByUser = true; _interruptChunkCount = 0; _flushAiBuffers(); // ادامه می‌دهیم تا صحبت کاربر شروع شود } else { return; // هنوز تعداد کافی نیست } } else { // اگر صدا پایین آمد، شمارنده را ریست کن _interruptChunkCount = 0; return; } } } // ۳. لاجیک عادی VAD و ارسال صدا double currentThreshold = vadThreshold; if (rms > currentThreshold) { if (_silenceTimer?.isActive ?? false) { _silenceTimer!.cancel(); } if (!_isSpeechActive) { _isSpeechActive = true; setState(() => _statusText = '🎤 در حال صحبت...'); } } else { if (_isSpeechActive && (_silenceTimer == null || !_silenceTimer!.isActive)) { _silenceTimer = Timer(const Duration(milliseconds: silenceTimeoutMs), () { _isSpeechActive = false; _silenceTimer = null; setState(() => _statusText = 'درحال گوش دادن...'); }); } } // ۴. ارسال داده‌ها if (_isSpeechActive || (_silenceTimer?.isActive ?? false)) { _sendBuffer.addAll(chunkData); while (_sendBuffer.length >= targetChunkSize) { final chunkToSend = _sendBuffer.sublist(0, targetChunkSize); final remaining = _sendBuffer.sublist(targetChunkSize); _sendBuffer.clear(); _sendBuffer.addAll(remaining); final base64Audio = base64Encode(Uint8List.fromList(chunkToSend)); _socket!.emit('audio_chunk', base64Audio); _chunkCount++; } } else { if (_sendBuffer.isNotEmpty) { final base64Audio = base64Encode(Uint8List.fromList(_sendBuffer)); _socket!.emit('audio_chunk', base64Audio); _sendBuffer.clear(); } } } Future _stopRecordingStream() async { _silenceTimer?.cancel(); _silenceTimer = null; if (!_isRecording) return; try { setState(() { _isRecording = false; _statusText = 'متوقف شد'; }); _waveController.stop(); _vadTimer?.cancel(); await _audioStreamSubscription?.cancel(); _audioStreamSubscription = null; await _audioRecorder.stop(); _sendBuffer.clear(); } catch (e) { debugPrint('Stop Recording Error: $e'); } } // --------------------------------------------------------------------------- // Receive and Play (AI Output) // --------------------------------------------------------------------------- /// کاهش حجم صدای PCM16 به صورت مستقیم Uint8List _reduceAudioVolume(Uint8List audioData, double volumeFactor) { final result = Uint8List(audioData.length); debugPrint( '🔉 Reducing volume: ${audioData.length} bytes, factor: $volumeFactor'); int maxOriginal = 0; int maxReduced = 0; for (int i = 0; i < audioData.length - 1; i += 2) { // خواندن sample به صورت Little Endian 16-bit signed integer int low = audioData[i]; int high = audioData[i + 1]; int sample = (high << 8) | low; // تبدیل به signed if (sample >= 32768) { sample -= 65536; } if (sample.abs() > maxOriginal) maxOriginal = sample.abs(); // کاهش شدید حجم - ضرب در ضریب خیلی کوچک double reduced = sample * volumeFactor; int newSample = reduced.round(); // محدود کردن newSample = newSample.clamp(-32768, 32767); if (newSample.abs() > maxReduced) maxReduced = newSample.abs(); // تبدیل به unsigned if (newSample < 0) { newSample += 65536; } // نوشتن Little Endian result[i] = newSample & 0xFF; result[i + 1] = (newSample >> 8) & 0xFF; } debugPrint( '✅ Volume reduced - Max original: $maxOriginal, Max reduced: $maxReduced'); return result; } void _handleGeminiAudioChunk(dynamic data) { try { _ignoreAudioDuringAIPlayback = true; _aiPlaybackStartTime = DateTime.now(); // ثبت زمان شروع دریافت String base64String; if (data is String) { base64String = data; } else if (data is Map && data['data'] != null) { base64String = data['data'] as String; } else { return; } if (base64String.startsWith('data:')) { base64String = base64String.split(',').last; } final Uint8List bytes = base64Decode(base64String); if (bytes.isEmpty) return; _fullResponseAccumulator.add(bytes); if (mounted && !_isAiSpeaking) { setState(() { _isAiSpeaking = true; _statusText = '🔄 منتظر پاسخ AI...'; }); } _playbackTriggerTimer?.cancel(); _playbackTriggerTimer = Timer(const Duration(milliseconds: 800), () { debugPrint( '✅ Response complete. Total size: ${_fullResponseAccumulator.length} bytes'); _playAccumulatedAudio(); }); } catch (e) { debugPrint('❌ Error receiving chunk: $e'); } } // متد اصلی پخش صدا با منطق جداگانه برای وب و موبایل Future _playAccumulatedAudio() async { if (_isSpeechActive) { debugPrint('⚠️ User is speaking, cancelling AI playback'); _fullResponseAccumulator.clear(); _isAiSpeaking = false; return; } if (_isPlayingFromQueue) { debugPrint('⚠️ Already playing, skipping...'); return; } final Uint8List totalAudioData = _fullResponseAccumulator.toBytes(); _fullResponseAccumulator.clear(); if (totalAudioData.isEmpty) { debugPrint('⚠️ No audio data to play'); return; } // کاهش حجم صدا در سطح sample - محدود کردن به 8% حجم اصلی debugPrint('📊 Original audio size: ${totalAudioData.length} bytes'); final reducedAudioData = _reduceAudioVolume(totalAudioData, 0.08); debugPrint('📊 Reduced audio size: ${reducedAudioData.length} bytes'); _isPlayingFromQueue = true; try { if (mounted) { setState(() => _statusText = '🔊 AI در حال صحبت...'); } // --- تغییرات برای وب --- if (kIsWeb) { // در وب به جای ذخیره فایل، مستقیم از بافر پخش می‌کنیم await _audioPlayer.startPlayer( fromDataBuffer: reducedAudioData, codec: fsp.Codec.pcm16, sampleRate: geminiSampleRate, numChannels: 1, whenFinished: () { _onPlaybackFinished(); }, ); } else { // در موبایل (کد قبلی): ذخیره در فایل موقت و پخش final tempDir = await getTemporaryDirectory(); final tempFile = File( '${tempDir.path}/ai_response_${DateTime.now().millisecondsSinceEpoch}.pcm'); await tempFile.writeAsBytes(reducedAudioData); await _audioPlayer.startPlayer( fromURI: tempFile.path, codec: fsp.Codec.pcm16, sampleRate: geminiSampleRate, numChannels: 1, whenFinished: () { _onPlaybackFinished(tempFile: tempFile); }, ); } } catch (e) { debugPrint('❌ Playback Error: $e'); _isPlayingFromQueue = false; if (mounted) { setState(() { _isAiSpeaking = false; _statusText = _isRecording ? '👂 در حال گوش دادن...' : 'آماده'; }); } } } // متد کمکی برای پایان پخش و پاکسازی void _onPlaybackFinished({File? tempFile}) { debugPrint('✅ Playback finished'); if (!_wasStoppedByUser) { _ignoreAudioDuringAIPlayback = false; _aiPlaybackStartTime = null; // ریست زمان پخش } // پاک کردن فایل فقط در موبایل (چون در وب فایلی نیست) if (tempFile != null) { try { if (tempFile.existsSync()) tempFile.deleteSync(); } catch (e) { /* ignore */ } } if (!_wasStoppedByUser) { if (mounted) { setState(() { _isAiSpeaking = false; _isPlayingFromQueue = false; _statusText = _isRecording ? '👂 در حال گوش دادن...' : 'آماده'; }); } } else { _wasStoppedByUser = false; } } @override void dispose() { _stopAiPlayback(); _audioStreamSubscription?.cancel(); if (_isRecording) _audioRecorder.stop(); _vadTimer?.cancel(); _speakingTimer?.cancel(); _socket?.disconnect(); _socket?.dispose(); _orbController.dispose(); _rippleController.dispose(); _waveController.dispose(); _audioRecorder.dispose(); if (_isPlayerInitialized) { _audioPlayer.closePlayer(); } super.dispose(); } // --------------------------------------------------------------------------- // UI Design & Theme Colors (DESIGN UPDATE ONLY) // --------------------------------------------------------------------------- List _getGradientColors() { if (!_isConnected) { return [ const Color(0xFF2C3E50), const Color(0xFF000000), ]; } if (_isAiSpeaking) { return [ const Color(0xFF1A2980), const Color(0xFF26D0CE), ]; } if (_isSpeechActive) { return [ const Color(0xFFCB356B), const Color(0xFFBD3F32), ]; } return [ const Color(0xFF141E30), const Color(0xFF243B55), ]; } Color _getPrimaryGlowColor() { if (!_isConnected) return Colors.red.shade400; if (_isAiSpeaking) return const Color.fromARGB(255, 0, 170, 170); if (_isSpeechActive) return const Color.fromARGB(255, 196, 4, 4); return Colors.white.withOpacity(0.5); } @override Widget build(BuildContext context) { return PopScope( canPop: true, onPopInvoked: (didPop) async { if (didPop) { await _stopRecordingStream(); } }, child: Dialog( backgroundColor: Colors.transparent, insetPadding: EdgeInsets.zero, child: AnimatedContainer( duration: const Duration(milliseconds: 1000), curve: Curves.easeInOut, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: _getGradientColors(), ), ), child: Stack( children: [ Positioned.fill( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), child: Container(color: Colors.transparent), ), ), SafeArea( child: Column( children: [ _buildTopBar(context), const Spacer(), _buildCentralVisualizer(), const Spacer(), _buildStatusArea(), const SizedBox(height: 40), ], ), ), ], ), ), ), ); } Widget _buildTopBar(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( onPressed: () async { await _stopRecordingStream(); if (context.mounted) Navigator.pop(context); }, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white.withOpacity(0.1), ), child: const Icon(Icons.close, color: Colors.white, size: 20), ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Colors.black26, borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.white10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.auto_awesome, color: Colors.white70, size: 14), const SizedBox(width: 8), DidvanText( 'هوشان ، دستیار صوتی دیدوان', color: Colors.white.withOpacity(0.9), fontSize: 12, fontWeight: FontWeight.w600, ), ], ), ), const SizedBox(width: 48), ], ), ); } Widget _buildCentralVisualizer() { return SizedBox( height: 350, width: 350, child: Stack( alignment: Alignment.center, children: [ AnimatedBuilder( animation: _rippleController, builder: (context, child) { return CustomPaint( painter: RipplePainter( animationValue: _rippleController.value, color: _getPrimaryGlowColor(), ), size: const Size(350, 350), ); }, ), AnimatedBuilder( animation: _waveController, builder: (context, child) { return CustomPaint( painter: ModernWavePainter( waveHeights: _audioWaveHeights, color: _getPrimaryGlowColor(), ), size: const Size(280, 280), ); }, ), AnimatedBuilder( animation: _orbController, builder: (context, child) { final scale = 1.0 + (_orbController.value * 0.05) + (math.max(0, _audioWaveHeights.reduce(math.max) * 0.1)); return Transform.scale( scale: scale, child: Container( width: 140, height: 140, decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: _getPrimaryGlowColor().withOpacity(0.6), blurRadius: 50, spreadRadius: 10, ), BoxShadow( color: Colors.white.withOpacity(0.3), blurRadius: 20, spreadRadius: 0, ), ], gradient: RadialGradient( colors: [ Colors.white.withOpacity(0.95), _getPrimaryGlowColor().withOpacity(0.8), _getPrimaryGlowColor().withOpacity(0.2), ], stops: const [0.1, 0.6, 1.0], ), ), child: Center( child: Icon( _isAiSpeaking ? Icons.graphic_eq : Icons.mic_none_rounded, color: Colors.white.withOpacity(0.9), size: 40, ), ), ), ); }, ), ], ), ); } Widget _buildStatusArea() { return Column( mainAxisSize: MainAxisSize.min, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (Widget child, Animation animation) { return FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0.0, 0.2), end: Offset.zero, ).animate(animation), child: child, ), ); }, child: DidvanText( _statusText, key: ValueKey(_statusText), fontSize: 18, fontWeight: FontWeight.w500, color: Colors.white.withOpacity(0.95), textAlign: TextAlign.center, ), ), const SizedBox(height: 12), ], ); } } class ModernWavePainter extends CustomPainter { final List waveHeights; final Color color; ModernWavePainter({required this.waveHeights, required this.color}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color.withOpacity(0.3) ..style = PaintingStyle.stroke ..strokeWidth = 2.0 ..strokeCap = StrokeCap.round; final center = Offset(size.width / 2, size.height / 2); final radius = size.width / 2.5; for (int j = 0; j < 2; j++) { final path = Path(); final count = waveHeights.length; final angleStep = (2 * math.pi) / count; for (int i = 0; i <= count; i++) { final index = i % count; final angle = i * angleStep; final offset = j * 5; final waveVal = waveHeights[(index + offset) % count]; final r = radius + (waveVal * 30) + (j * 15); final x = center.dx + r * math.cos(angle); final y = center.dy + r * math.sin(angle); if (i == 0) { path.moveTo(x, y); } else { final prevAngle = (i - 1) * angleStep; final prevIndex = (i - 1) % count; final prevWave = waveHeights[(prevIndex + offset) % count]; final prevR = radius + (prevWave * 30) + (j * 15); final prevX = center.dx + prevR * math.cos(prevAngle); final prevY = center.dy + prevR * math.sin(prevAngle); final midX = (prevX + x) / 2; final midY = (prevY + y) / 2; path.quadraticBezierTo(prevX, prevY, midX, midY); } } path.close(); canvas.drawPath(path, paint); } } @override bool shouldRepaint(covariant ModernWavePainter oldDelegate) => true; } class RipplePainter extends CustomPainter { final double animationValue; final Color color; RipplePainter({required this.animationValue, required this.color}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1.0; final center = Offset(size.width / 2, size.height / 2); final maxRadius = size.width / 2; for (int i = 0; i < 3; i++) { final progress = (animationValue + (i / 3)) % 1.0; final radius = progress * maxRadius; final opacity = (1.0 - progress).clamp(0.0, 1.0); paint.color = color.withOpacity(opacity * 0.3); canvas.drawCircle(center, radius, paint); } } @override bool shouldRepaint(covariant RipplePainter oldDelegate) => true; }