didvan-app/lib/views/widgets/ai_voice_chat_dialog.dart

1042 lines
33 KiB
Dart

// 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<AiVoiceChatDialog> createState() => _AiVoiceChatDialogState();
}
class _AiVoiceChatDialogState extends State<AiVoiceChatDialog>
with TickerProviderStateMixin {
final RealtimeVoiceSession _session = RealtimeVoiceSession();
final ScrollController _transcriptController = ScrollController();
final List<double> _waveformSamples = <double>[];
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 = <String>[
..._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<String> 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<String> 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<double> samples;
final Color accent;
final bool active;
@override
Widget build(BuildContext context) {
const barCount = 32;
final effectiveSamples = <double>[];
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<double> 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;
}