1042 lines
33 KiB
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;
|
|
}
|