// ignore_for_file: deprecated_member_use import 'dart:async'; import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:didvan/services/ai/realtime_voice_session.dart'; class AiVoiceChatDialog extends StatefulWidget { const AiVoiceChatDialog({super.key}); @override State createState() => _AiVoiceChatDialogState(); } class _AiVoiceChatDialogState extends State with TickerProviderStateMixin { final RealtimeVoiceSession _session = RealtimeVoiceSession(); final ScrollController _transcriptController = ScrollController(); final List _waveformSamples = []; late final AnimationController _ambientController; late final AnimationController _orbPulseController; late final AnimationController _entryController; late final AnimationController _statusDotController; late final VoidCallback _sessionListener; double _energy = 0.06; double _transient = 0; double _pulse = 0.6; double _ambient = 0; DateTime _lastFrame = DateTime.now(); // ───── color palette ───── static const Color _bg = Color(0xFF05090F); static const Color _accentBlue = Color(0xFF3B8BFF); static const Color _accentPurple = Color(0xFF9B6DFF); static const Color _accentGreen = Color(0xFF2DD5A0); static const Color _textPrimary = Color(0xFFE8F0FF); static const Color _textSecondary = Color(0xFF6A85AD); @override void initState() { super.initState(); _ambientController = AnimationController( vsync: this, duration: const Duration(seconds: 12), )..repeat(); _orbPulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2200), lowerBound: 0.55, upperBound: 1.05, )..repeat(reverse: true); _entryController = AnimationController( vsync: this, duration: const Duration(milliseconds: 700), )..forward(); _statusDotController = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..repeat(reverse: true); _sessionListener = () { if (!mounted) return; _waveformSamples.add(_session.microphoneLevel); if (_waveformSamples.length > 80) _waveformSamples.removeAt(0); final assistantDrive = _session.assistantLevel.clamp(0.0, 1.0); final micDrive = _session.microphoneLevel.clamp(0.0, 1.0); final desired = (_session.isAssistantActive ? (0.62 + assistantDrive) : _session.isUserSpeaking ? (0.38 + micDrive * 0.9) : 0.06) * (_session.phase == VoiceSessionPhase.error ? 0.55 : 1); _energy += (desired - _energy) * 0.14; if (_session.liveAssistantTranscript.isNotEmpty) { _transient = math.min(_transient + 0.12, 1.0); } else { _transient *= 0.88; } setState(() {}); _scheduleTranscriptScroll(); }; _session.addListener(_sessionListener); unawaited(_session.start()); _ambientController.addListener(_onAnimTick); _orbPulseController.addListener(_onAnimTick); } @override void dispose() { _ambientController ..removeListener(_onAnimTick) ..dispose(); _orbPulseController ..removeListener(_onAnimTick) ..dispose(); _entryController.dispose(); _statusDotController.dispose(); _session.removeListener(_sessionListener); unawaited(_session.close()); _transcriptController.dispose(); super.dispose(); } void _onAnimTick() { final dt = DateTime.now().difference(_lastFrame).inMilliseconds / 1000.0; if (dt <= 0.001) return; _lastFrame = DateTime.now(); _ambient = _ambientController.value * math.pi * 2; final targetPulse = (_session.isAssistantActive ? 1.0 : 0.65) * (_session.isUserSpeaking ? 1.2 : 0.75); _pulse += (targetPulse - _pulse) * (0.06 + dt * 1.2); _pulse = _pulse.clamp(0.5, 1.16); if (mounted) setState(() {}); } void _scheduleTranscriptScroll() { if (!_transcriptController.hasClients) return; final maxExtent = _transcriptController.position.maxScrollExtent; if (maxExtent <= 0) return; _transcriptController.animateTo( maxExtent, duration: const Duration(milliseconds: 220), curve: Curves.easeOut, ); } Color get _orbAccent { if (_session.phase == VoiceSessionPhase.error) return Colors.redAccent; if (_session.isAssistantActive) return _accentBlue; if (_session.isUserSpeaking) return _accentGreen; return _accentPurple; } @override Widget build(BuildContext context) { final messages = [ ..._session.assistantMessages, if (_session.liveAssistantTranscript.trim().isNotEmpty) _session.liveAssistantTranscript, ]; final canInterrupt = _session.isAssistantActive && _session.phase != VoiceSessionPhase.error; final canSpeak = _session.phase != VoiceSessionPhase.closed; return Scaffold( backgroundColor: _bg, body: AnimatedBuilder( animation: _entryController, builder: (context, child) { return Opacity( opacity: _entryController.value, child: child, ); }, child: Stack( fit: StackFit.expand, children: [ // ── background layers ── _BackgroundLayer(ambient: _ambient, energy: _energy, accent: _orbAccent), // ── main layout ── SafeArea( child: Column( children: [ _buildTopBar(context), Expanded( child: _buildOrbSection(), ), _buildTranscriptPanel(messages), const SizedBox(height: 12), _buildControlBar(canInterrupt, canSpeak), const SizedBox(height: 20), ], ), ), ], ), ), ); } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // TOP BAR // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Widget _buildTopBar(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), child: Row( children: [ // Close _GlassButton( onTap: _onClose, child: const Icon(Icons.arrow_back_ios_new_rounded, color: _textPrimary, size: 18), ), const SizedBox(width: 14), // Title + status Expanded( child: Directionality( textDirection: TextDirection.rtl, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'هوشان', style: TextStyle( color: _textPrimary, fontSize: 20, fontWeight: FontWeight.w800, letterSpacing: -0.5, ), ), const SizedBox(height: 3), Row( children: [ AnimatedBuilder( animation: _statusDotController, builder: (_, __) => Container( width: 6, height: 6, decoration: BoxDecoration( shape: BoxShape.circle, color: _orbAccent.withOpacity( 0.5 + _statusDotController.value * 0.5), boxShadow: [ BoxShadow( color: _orbAccent.withOpacity(0.8), blurRadius: 4, ), ], ), ), ), const SizedBox(width: 6), Text( _session.statusText, style: const TextStyle( color: _textSecondary, fontSize: 12.5, fontWeight: FontWeight.w500, ), ), ], ), ], ), ), ), // AEC badge _GlassBadge( label: !_session.hardwareAecEnabled ? 'AEC محدود' : 'AEC فعال', icon: !_session.hardwareAecEnabled ? Icons.warning_amber_rounded : Icons.shield_rounded, color: !_session.hardwareAecEnabled ? Colors.orange.shade300 : _accentGreen, ), ], ), ); } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ORB SECTION // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Widget _buildOrbSection() { return Center( child: LayoutBuilder( builder: (context, constraints) { final size = math.min(constraints.maxWidth, constraints.maxHeight) * 0.52; return Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: size, height: size, child: CustomPaint( painter: _OrbPainter( energy: (_energy * 0.8) + (_transient * 0.28) + (_pulse * 0.18), ambient: _ambient, heartbeat: _pulse, accent: _orbAccent, speaking: _session.isAssistantActive || _session.isUserSpeaking, wave: math.sin( _ambientController.value * 2 * math.pi) * 0.22, waveform: _waveformSamples, ), child: Center( child: AnimatedBuilder( animation: _orbPulseController, builder: (_, __) => Transform.scale( scale: 0.7 + (_orbPulseController.value - 0.55) * 0.06, child: Icon( _session.isAssistantActive ? Icons.graphic_eq_rounded : _session.isUserSpeaking ? Icons.mic_rounded : Icons.auto_awesome_rounded, color: Colors.white.withOpacity(0.9), size: size * 0.09, ), ), ), ), ), ), const SizedBox(height: 16), // Waveform bars below orb _WaveformBars( samples: _waveformSamples, accent: _orbAccent, active: _session.isUserSpeaking || _session.isAssistantActive, ), ], ); }, ), ); } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // TRANSCRIPT // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Widget _buildTranscriptPanel(List messages) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( height: 160, decoration: BoxDecoration( gradient: LinearGradient( colors: [ Colors.white.withOpacity(0.06), Colors.white.withOpacity(0.02), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(24), border: Border.all( color: _orbAccent.withOpacity(0.18), width: 1, ), ), child: Directionality( textDirection: TextDirection.rtl, child: messages.isEmpty ? _buildEmptyPlaceholder() : _buildTranscriptList(messages), ), ), ), ), ); } Widget _buildEmptyPlaceholder() { return Center( child: AnimatedOpacity( duration: const Duration(milliseconds: 300), opacity: _session.phase == VoiceSessionPhase.closed ? 0.3 : 0.7, child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.chat_bubble_outline_rounded, color: _textSecondary, size: 28), const SizedBox(height: 10), const Text( 'پاسخ‌های هوشان اینجا نمایش داده می‌شود', style: TextStyle( color: _textSecondary, fontSize: 13, height: 1.6, ), textAlign: TextAlign.center, ), ], ), ), ); } Widget _buildTranscriptList(List messages) { return ListView.separated( controller: _transcriptController, padding: const EdgeInsets.all(16), physics: const BouncingScrollPhysics(), itemCount: messages.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, i) { final isLive = i == messages.length - 1 && _session.liveAssistantTranscript.isNotEmpty; return AnimatedContainer( duration: const Duration(milliseconds: 300), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: isLive ? _orbAccent.withOpacity(0.08) : Colors.white.withOpacity(0.04), border: isLive ? Border.all(color: _orbAccent.withOpacity(0.25)) : null, ), child: Text( messages[i], style: TextStyle( color: isLive ? _textPrimary : _textPrimary.withOpacity(0.75), fontSize: 14.5, height: 1.8, fontWeight: isLive ? FontWeight.w600 : FontWeight.w400, ), ), ); }, ); } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // CONTROL BAR // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Widget _buildControlBar(bool canInterrupt, bool canSpeak) { final muted = _session.isMicMuted; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ // Mute toggle _ControlButton( onTap: () { _session.toggleMute(); setState(() {}); }, icon: muted ? Icons.mic_off_rounded : Icons.mic_rounded, label: muted ? 'میکروفون' : 'قطع میک', color: muted ? Colors.orange : const Color(0xFF1E2D40), accent: muted ? Colors.orange : _textSecondary, ), const SizedBox(width: 10), // Main action (interrupt / status) Expanded( child: _MainControlButton( canInterrupt: canInterrupt, isAssistantActive: _session.isAssistantActive, phase: _session.phase, accent: _orbAccent, onTap: canSpeak ? _session.interruptAssistant : null, ), ), const SizedBox(width: 10), // End call _ControlButton( onTap: _onClose, icon: Icons.call_end_rounded, label: 'خاتمه', color: const Color(0xFF3D0A14), accent: Colors.redAccent.shade100, ), ], ), ); } void _onClose() async { if (!_session.isSessionReady && !_session.isAssistantActive) { if (mounted) Navigator.of(context).pop(); return; } await _session.close(); if (!mounted) return; Navigator.of(context).pop(); } } // ═══════════════════════════════════ // BACKGROUND LAYER // ═══════════════════════════════════ class _BackgroundLayer extends StatelessWidget { const _BackgroundLayer({ required this.ambient, required this.energy, required this.accent, }); final double ambient; final double energy; final Color accent; @override Widget build(BuildContext context) { return Stack( fit: StackFit.expand, children: [ // Base dark const DecoratedBox( decoration: BoxDecoration(color: Color(0xFF05090F)), ), // Animated glow Positioned.fill( child: AnimatedContainer( duration: const Duration(milliseconds: 800), decoration: BoxDecoration( gradient: RadialGradient( center: Alignment( 0.15 + 0.08 * math.sin(ambient * 0.3), -0.6 + 0.06 * math.cos(ambient * 0.2), ), radius: 1.0, colors: [ accent.withOpacity(0.12 + energy * 0.08), Colors.transparent, ], ), ), ), ), // Subtle top gradient const Positioned( top: 0, left: 0, right: 0, child: SizedBox( height: 200, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x22182030), Colors.transparent], ), ), ), ), ), // Noise texture overlay Positioned.fill( child: CustomPaint(painter: _NoisePainter()), ), ], ); } } // ═══════════════════════════════════ // GLASS BUTTON // ═══════════════════════════════════ class _GlassButton extends StatelessWidget { const _GlassButton({required this.onTap, required this.child}); final VoidCallback onTap; final Widget child; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: ClipRRect( borderRadius: BorderRadius.circular(14), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: Container( width: 44, height: 44, decoration: BoxDecoration( color: Colors.white.withOpacity(0.08), borderRadius: BorderRadius.circular(14), border: Border.all( color: Colors.white.withOpacity(0.12), ), ), child: Center(child: child), ), ), ), ); } } // ═══════════════════════════════════ // GLASS BADGE // ═══════════════════════════════════ class _GlassBadge extends StatelessWidget { const _GlassBadge({ required this.label, required this.icon, required this.color, }); final String label; final IconData icon; final Color color; @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(20), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withOpacity(0.25)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 12, color: color), const SizedBox(width: 5), Text( label, style: TextStyle( fontSize: 11, color: color, fontWeight: FontWeight.w700, ), ), ], ), ), ), ); } } // ═══════════════════════════════════ // WAVEFORM BARS // ═══════════════════════════════════ class _WaveformBars extends StatelessWidget { const _WaveformBars({ required this.samples, required this.accent, required this.active, }); final List samples; final Color accent; final bool active; @override Widget build(BuildContext context) { const barCount = 32; final effectiveSamples = []; for (int i = 0; i < barCount; i++) { if (samples.isEmpty || !active) { effectiveSamples.add(0.06 + math.sin(i * 0.4) * 0.04); } else { final idx = ((i / barCount) * samples.length).floor(); final raw = idx < samples.length ? samples[idx] : 0.0; effectiveSamples.add(raw.clamp(0.05, 1.0)); } } return SizedBox( width: 200, height: 36, child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: List.generate(barCount, (i) { final h = effectiveSamples[i]; return AnimatedContainer( duration: const Duration(milliseconds: 80), width: 3, height: 4 + h * 30, margin: const EdgeInsets.symmetric(horizontal: 1.5), decoration: BoxDecoration( color: accent.withOpacity(0.5 + h * 0.5), borderRadius: BorderRadius.circular(2), ), ); }), ), ); } } // ═══════════════════════════════════ // CONTROL BUTTON (side) // ═══════════════════════════════════ class _ControlButton extends StatelessWidget { const _ControlButton({ required this.onTap, required this.icon, required this.label, required this.color, required this.accent, }); final VoidCallback? onTap; final IconData icon; final String label; final Color color; final Color accent; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: ClipRRect( borderRadius: BorderRadius.circular(18), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: Container( width: 72, height: 72, decoration: BoxDecoration( color: color.withOpacity(0.85), borderRadius: BorderRadius.circular(18), border: Border.all( color: accent.withOpacity(0.2), width: 1, ), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: accent, size: 24), const SizedBox(height: 5), Text( label, style: TextStyle( color: accent.withOpacity(0.85), fontSize: 10, fontWeight: FontWeight.w600, ), ), ], ), ), ), ), ); } } // ═══════════════════════════════════ // MAIN CONTROL BUTTON (center) // ═══════════════════════════════════ class _MainControlButton extends StatelessWidget { const _MainControlButton({ required this.canInterrupt, required this.isAssistantActive, required this.phase, required this.accent, required this.onTap, }); final bool canInterrupt; final bool isAssistantActive; final VoiceSessionPhase phase; final Color accent; final VoidCallback? onTap; String get _label { if (canInterrupt) return 'قطع پاسخ'; if (phase == VoiceSessionPhase.speaking) return 'در حال صحبت'; if (phase == VoiceSessionPhase.listening) return 'در حال گوش‌دادن'; if (phase == VoiceSessionPhase.thinking) return 'در حال فکر'; return 'آماده'; } IconData get _icon { if (canInterrupt) return Icons.stop_rounded; if (isAssistantActive) return Icons.graphic_eq_rounded; return Icons.hearing_rounded; } @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 300), height: 72, decoration: BoxDecoration( gradient: LinearGradient( colors: canInterrupt ? [ Colors.red.shade900.withOpacity(0.9), Colors.red.shade700.withOpacity(0.9), ] : [ accent.withOpacity(0.15), accent.withOpacity(0.08), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(18), border: Border.all( color: accent.withOpacity(canInterrupt ? 0.5 : 0.2), width: 1, ), boxShadow: canInterrupt ? [ BoxShadow( color: Colors.red.withOpacity(0.25), blurRadius: 16, offset: const Offset(0, 6), ), ] : [], ), child: ClipRRect( borderRadius: BorderRadius.circular(18), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( _icon, color: canInterrupt ? Colors.white : accent, size: 22, ), const SizedBox(width: 10), Text( _label, style: TextStyle( color: canInterrupt ? Colors.white : accent, fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: 0.3, ), ), ], ), ), ), ), ); } } // ═══════════════════════════════════ // ORB PAINTER // ═══════════════════════════════════ class _OrbPainter extends CustomPainter { _OrbPainter({ required this.energy, required this.ambient, required this.heartbeat, required this.accent, required this.speaking, required this.wave, required this.waveform, }) : _brush = Paint(); final double energy; final double ambient; final double heartbeat; final Color accent; final bool speaking; final double wave; final List waveform; final Paint _brush; @override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); final base = size.width * 0.33; final breath = 1.0 + 0.014 * math.sin(ambient * 0.9); final shake = speaking ? (math.sin(ambient * 4.0) * 0.006) : 0.0; final offset = Offset(shake * size.width, shake * size.height); final radius = base * breath * (0.94 + heartbeat * 0.12); // ── outer glow ── final glowPaint = Paint() ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 55) ..color = accent.withOpacity(0.28 + energy * 0.12); canvas.drawCircle(center + offset, base * (1.55 + energy * 1.15), glowPaint); // ── secondary glow ring ── final glow2 = Paint() ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 28) ..color = accent.withOpacity(0.14); canvas.drawCircle(center, base * 1.2, glow2); final bounds = Rect.fromCircle(center: center + offset, radius: radius); final path = Path()..addOval(bounds); // ── drop shadow ── canvas.drawShadow(path, accent.withOpacity(0.5), 28, true); // ── core sphere ── final core = Paint() ..shader = RadialGradient( center: Alignment( 0.2 * math.sin(ambient), 0.18 * math.cos(ambient * 0.75), ), colors: [ Colors.white, Colors.white.withOpacity(0.94), accent.withOpacity(0.35), Colors.transparent, ], stops: const [0.0, 0.12, 0.55, 1.0], ).createShader(bounds) ..style = PaintingStyle.fill; canvas.drawCircle( center + Offset(wave * 7, math.cos(ambient * 1.4) * 3), base, core); // ── colored overlay ── final overlay = Paint() ..shader = RadialGradient( center: const Alignment(-0.3, -0.4), radius: 1.0, colors: [ accent.withOpacity(0.28), Colors.transparent, ], ).createShader(bounds); canvas.drawCircle(center + offset, radius, overlay); // ── sweep gloss ── final glossy = Paint() ..shader = SweepGradient( startAngle: ambient * 0.9, endAngle: ambient * 0.9 + math.pi * 1.9, colors: [ accent.withOpacity(0.0), accent.withOpacity(0.2), Colors.white.withOpacity(0.08), accent.withOpacity(0.15), accent.withOpacity(0.0), ], stops: const [0.0, 0.22, 0.54, 0.8, 1.0], ).createShader(Rect.fromCircle(center: center, radius: base * 1.08)); canvas.drawCircle(center + Offset(0, -base * 0.02), base * 1.08, glossy); // ── highlight edge ── final edge = Paint() ..shader = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Colors.white.withOpacity(0.7), Colors.transparent, Colors.white.withOpacity(0.18), Colors.transparent, ], stops: const [0.0, 0.18, 0.7, 1.0], ).createShader(Rect.fromCircle(center: center, radius: base * 1.02)) ..style = PaintingStyle.stroke ..strokeWidth = 2.0; canvas.drawCircle(center + Offset(shake * 2, 0), base * 1.02, edge); // ── waveform ring ── if (waveform.isNotEmpty && speaking) { final wPath = Path(); final len = waveform.length; final ringR = base * 1.22; for (int i = 0; i < len; i++) { final t = i / (len - 1); final r = ringR + waveform[i] * 10 + wave * 5; final x = center.dx + r * math.cos(t * 2 * math.pi + ambient); final y = center.dy + r * math.sin(t * 2 * math.pi + ambient); if (i == 0) { wPath.moveTo(x, y); } else { wPath.lineTo(x, y); } } wPath.close(); _brush ..style = PaintingStyle.stroke ..strokeWidth = 1.4 ..color = accent.withOpacity(0.22); canvas.drawPath(wPath, _brush); } } @override bool shouldRepaint(covariant _OrbPainter old) => old.energy != energy || old.ambient != ambient || old.heartbeat != heartbeat || old.accent != accent || old.speaking != speaking || old.wave != wave || old.waveform.length != waveform.length; } // ═══════════════════════════════════ // NOISE PAINTER (subtle texture) // ═══════════════════════════════════ class _NoisePainter extends CustomPainter { final _rand = math.Random(42); @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = Colors.white.withOpacity(0.012); for (int i = 0; i < 600; i++) { final x = _rand.nextDouble() * size.width; final y = _rand.nextDouble() * size.height; canvas.drawCircle(Offset(x, y), 0.7, paint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }