elevenlabs_flutter_sdk 0.0.1
elevenlabs_flutter_sdk: ^0.0.1 copied to clipboard
Flutter plugin for ElevenLabs conversational agents on Android and iOS using a unified Dart API.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:elevenlabs_flutter_sdk/elevenlabs_flutter_sdk.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
// Color palette
class AppColors {
static const Color primary = Color(0xFF6366F1);
static const Color primaryDark = Color(0xFF4F46E5);
static const Color secondary = Color(0xFF8B5CF6);
static const Color accent = Color(0xFF06B6D4);
static const Color background = Color(0xFF0F0F1A);
static const Color surface = Color(0xFF1A1A2E);
static const Color surfaceLight = Color(0xFF252540);
static const Color textPrimary = Color(0xFFF8FAFC);
static const Color textSecondary = Color(0xFF94A3B8);
static const Color error = Color(0xFFEF4444);
static const Color success = Color(0xFF22C55E);
static const Color warning = Color(0xFFF59E0B);
}
// Main App - just provides MaterialApp
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: AppColors.background,
colorScheme: const ColorScheme.dark(
primary: AppColors.primary,
secondary: AppColors.secondary,
surface: AppColors.surface,
),
),
home: const HomeScreen(),
);
}
}
// Home Screen - separate widget with proper Navigator context
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final ElevenlabsFlutterSdk _sdk = ElevenlabsFlutterSdk();
final TextEditingController _agentIdController = TextEditingController();
final TextEditingController _tokenController = TextEditingController();
bool _isLoading = false;
bool _showOpenSettings = false;
bool _micGranted = false;
String _status = 'Enter your Agent ID to begin';
StatusType _statusType = StatusType.neutral;
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat();
unawaited(_refreshPermissionState());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_shimmerController.dispose();
_agentIdController.dispose();
_tokenController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_refreshPermissionState());
}
}
Future<void> _startSession() async {
if (_isLoading) return;
setState(() {
_isLoading = true;
_status = 'Initializing...';
_statusType = StatusType.loading;
});
try {
final bool micReady = await _ensureMicrophonePermission();
if (!micReady) {
setState(() {
_status = _showOpenSettings
? 'Microphone permission blocked. Open Settings and allow Microphone.'
: 'Microphone permission required';
_statusType = StatusType.error;
_isLoading = false;
});
return;
}
final String agentId = _agentIdController.text.trim();
final String token = _tokenController.text.trim();
if (agentId.isEmpty && token.isEmpty) {
setState(() {
_status = 'Enter an Agent ID or Token';
_statusType = StatusType.warning;
_isLoading = false;
});
return;
}
if (agentId.isNotEmpty && token.isNotEmpty) {
setState(() {
_status = 'Use only Agent ID or Token, not both';
_statusType = StatusType.warning;
_isLoading = false;
});
return;
}
if (!mounted) return;
setState(() {
_status = 'Connecting to agent...';
_statusType = StatusType.loading;
});
debugPrint('Navigating to VoiceSessionScreen with agentId: $agentId');
await Navigator.of(context).push(
PageRouteBuilder<void>(
pageBuilder: (context, animation, secondaryAnimation) =>
VoiceSessionScreen(
sdk: _sdk,
agentId: agentId.isNotEmpty ? agentId : null,
conversationToken: token.isNotEmpty ? token : null,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
)),
child: child,
),
);
},
transitionDuration: const Duration(milliseconds: 400),
),
);
if (!mounted) return;
setState(() {
_status = 'Session ended';
_statusType = StatusType.neutral;
});
} catch (e, stackTrace) {
debugPrint('═══════════════════════════════════════════');
debugPrint('HOME SCREEN - CONNECTION ERROR');
debugPrint('═══════════════════════════════════════════');
debugPrint('Error: $e');
debugPrint('Type: ${e.runtimeType}');
debugPrint('Stack: $stackTrace');
debugPrint('═══════════════════════════════════════════');
if (!mounted) return;
setState(() {
final String errorText = e.toString();
_status =
'Error: ${errorText.length > 100 ? '${errorText.substring(0, 100)}...' : errorText}';
_statusType = StatusType.error;
});
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _endSession() async {
try {
await _sdk.endSession();
if (!mounted) return;
setState(() {
_status = 'Disconnected';
_statusType = StatusType.neutral;
});
} catch (_) {}
}
Future<bool> _ensureMicrophonePermission() async {
final PermissionStatus current = await Permission.microphone.status;
if (current.isGranted) {
_micGranted = true;
_showOpenSettings = false;
return true;
}
final PermissionStatus requested = await Permission.microphone.request();
if (requested.isGranted) {
_micGranted = true;
_showOpenSettings = false;
return true;
}
_micGranted = false;
// Do not force navigation to iOS Settings.
// Keep user in-app and show error state instead.
_showOpenSettings =
current.isPermanentlyDenied ||
current.isRestricted ||
requested.isPermanentlyDenied ||
requested.isRestricted;
return false;
}
Future<void> _refreshPermissionState() async {
final PermissionStatus status = await Permission.microphone.status;
if (!mounted) return;
setState(() {
_micGranted = status.isGranted;
_showOpenSettings = status.isPermanentlyDenied || status.isRestricted;
});
}
Future<void> _requestMicrophonePermission() async {
final bool granted = await _ensureMicrophonePermission();
if (!mounted) return;
setState(() {
_status = granted
? 'Microphone permission granted'
: _showOpenSettings
? 'Microphone blocked. Open Settings and allow Microphone.'
: 'Microphone permission denied';
_statusType = granted ? StatusType.success : StatusType.warning;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.background,
Color(0xFF1A1025),
AppColors.background,
],
),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 48),
_buildHeader(),
const SizedBox(height: 48),
_buildStatusCard(),
const SizedBox(height: 32),
_buildInputCard(),
const SizedBox(height: 32),
_buildActionButtons(),
const SizedBox(height: 48),
],
),
),
),
),
);
}
Widget _buildHeader() {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.secondary],
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.4),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: const Icon(Icons.mic_rounded, color: Colors.white, size: 40),
),
const SizedBox(height: 24),
const Text(
'ElevenLabs',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Text(
'Voice AI SDK Demo',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary.withValues(alpha: 0.8),
letterSpacing: 0.5,
),
),
],
);
}
Widget _buildStatusCard() {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: _statusType.backgroundColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _statusType.borderColor, width: 1),
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 10,
height: 10,
decoration: BoxDecoration(
color: _statusType.dotColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _statusType.dotColor.withValues(alpha: 0.5),
blurRadius: 8,
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
_status,
style: TextStyle(
color: _statusType.textColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (_statusType == StatusType.loading)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(_statusType.dotColor),
),
),
],
),
);
}
Widget _buildInputCard() {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.surfaceLight.withValues(alpha: 0.5),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Configuration',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Enter your Agent ID or Conversation Token',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
const SizedBox(height: 24),
_buildTextField(
controller: _agentIdController,
label: 'Agent ID',
hint: 'agent_xxxxx',
icon: Icons.smart_toy_outlined,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(child: Divider(color: AppColors.surfaceLight)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'OR',
style: TextStyle(
color: AppColors.textSecondary.withValues(alpha: 0.5),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
Expanded(child: Divider(color: AppColors.surfaceLight)),
],
),
const SizedBox(height: 16),
_buildTextField(
controller: _tokenController,
label: 'Conversation Token',
hint: 'Your private token',
icon: Icons.key_outlined,
obscure: true,
),
],
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required String hint,
required IconData icon,
bool obscure = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
TextField(
controller: controller,
obscureText: obscure,
style: const TextStyle(color: AppColors.textPrimary, fontSize: 15),
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(
color: AppColors.textSecondary.withValues(alpha: 0.4),
),
prefixIcon: Icon(icon, color: AppColors.textSecondary, size: 20),
filled: true,
fillColor: AppColors.surfaceLight.withValues(alpha: 0.5),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.surfaceLight, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
),
),
],
);
}
Widget _buildActionButtons() {
return Column(
children: [
if (!_micGranted) ...[
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: _requestMicrophonePermission,
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColors.primary.withValues(alpha: 0.5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.mic_none_rounded, size: 18),
label: const Text('Request Microphone Permission'),
),
),
const SizedBox(height: 12),
],
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: _isLoading
? [AppColors.surfaceLight, AppColors.surfaceLight]
: [AppColors.primary, AppColors.secondary],
),
boxShadow: _isLoading
? []
: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.4),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _isLoading ? null : _startSession,
borderRadius: BorderRadius.circular(16),
child: Center(
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation(
AppColors.textPrimary,
),
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_arrow_rounded,
color: Colors.white,
size: 24,
),
SizedBox(width: 8),
Text(
'Start Session',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
},
),
const SizedBox(height: 12),
if (_showOpenSettings) ...[
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () async {
await openAppSettings();
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColors.warning.withValues(alpha: 0.5)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
icon: const Icon(Icons.settings_outlined, size: 18),
label: const Text('Open iOS Settings'),
),
),
const SizedBox(height: 12),
],
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton(
onPressed: _endSession,
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColors.surfaceLight, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'End Session',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
}
enum StatusType {
neutral,
loading,
success,
warning,
error;
Color get backgroundColor => switch (this) {
StatusType.neutral => AppColors.surfaceLight.withValues(alpha: 0.3),
StatusType.loading => AppColors.primary.withValues(alpha: 0.1),
StatusType.success => AppColors.success.withValues(alpha: 0.1),
StatusType.warning => AppColors.warning.withValues(alpha: 0.1),
StatusType.error => AppColors.error.withValues(alpha: 0.1),
};
Color get borderColor => switch (this) {
StatusType.neutral => AppColors.surfaceLight,
StatusType.loading => AppColors.primary.withValues(alpha: 0.3),
StatusType.success => AppColors.success.withValues(alpha: 0.3),
StatusType.warning => AppColors.warning.withValues(alpha: 0.3),
StatusType.error => AppColors.error.withValues(alpha: 0.3),
};
Color get dotColor => switch (this) {
StatusType.neutral => AppColors.textSecondary,
StatusType.loading => AppColors.primary,
StatusType.success => AppColors.success,
StatusType.warning => AppColors.warning,
StatusType.error => AppColors.error,
};
Color get textColor => switch (this) {
StatusType.neutral => AppColors.textSecondary,
StatusType.loading => AppColors.primary,
StatusType.success => AppColors.success,
StatusType.warning => AppColors.warning,
StatusType.error => AppColors.error,
};
}
// Voice Session Screen
class VoiceSessionScreen extends StatefulWidget {
const VoiceSessionScreen({
super.key,
required this.sdk,
this.agentId,
this.conversationToken,
});
final ElevenlabsFlutterSdk sdk;
final String? agentId;
final String? conversationToken;
@override
State<VoiceSessionScreen> createState() => _VoiceSessionScreenState();
}
class _VoiceSessionScreenState extends State<VoiceSessionScreen>
with TickerProviderStateMixin {
StreamSubscription<String>? _modeSub;
StreamSubscription<String>? _statusSub;
StreamSubscription<String>? _connectSub;
String _mode = 'listening';
bool _isConnected = false;
bool _hasFailed = false;
String? _errorMessage;
late final AnimationController _pulseController;
late final AnimationController _waveController;
late final AnimationController _glowController;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
_waveController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 3000),
)..repeat();
_glowController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat(reverse: true);
unawaited(_startSessionAndEnableVoice());
_modeSub = widget.sdk.onModeChange.listen((String mode) {
if (!mounted) return;
debugPrint('Mode changed: $mode');
setState(() => _mode = mode.toLowerCase());
});
_statusSub = widget.sdk.onStatusChange.listen((String status) {
if (!mounted) return;
debugPrint('Status changed: $status');
// Status changes like "connected", "disconnected" etc.
final lowerStatus = status.toLowerCase();
if (lowerStatus == 'connected') {
setState(() => _isConnected = true);
} else if (lowerStatus == 'disconnected') {
setState(() => _isConnected = false);
}
});
_connectSub = widget.sdk.onConnect.listen((String conversationId) {
if (!mounted) return;
debugPrint('Connected with conversation ID: $conversationId');
setState(() => _isConnected = true);
});
}
Future<void> _startSessionAndEnableVoice() async {
try {
debugPrint('Starting session...');
debugPrint('Agent ID: ${widget.agentId}');
debugPrint(
'Token: ${widget.conversationToken != null ? "[provided]" : "null"}');
if ((widget.conversationToken ?? '').isNotEmpty) {
debugPrint('Using private session with token');
await widget.sdk.startPrivateSession(
conversationToken: widget.conversationToken!,
);
} else {
debugPrint('Using public session with agentId: ${widget.agentId}');
await widget.sdk.startPublicSession(agentId: widget.agentId!);
}
debugPrint('Session started, enabling voice input...');
await widget.sdk.enableVoiceInput();
debugPrint('Voice input enabled');
if (!mounted) return;
setState(() => _isConnected = true);
} catch (e, stackTrace) {
debugPrint('═══════════════════════════════════════════');
debugPrint('CONNECTION ERROR');
debugPrint('═══════════════════════════════════════════');
debugPrint('Error: $e');
debugPrint('Type: ${e.runtimeType}');
debugPrint('Stack trace:\n$stackTrace');
debugPrint('═══════════════════════════════════════════');
if (!mounted) return;
// Show the raw error for debugging
String rawError = e.toString();
String displayError = rawError;
// Try to extract meaningful message from PlatformException
if (rawError.contains('PlatformException')) {
final parts =
rawError.replaceFirst('PlatformException(', '').split(', ');
if (parts.length >= 2) {
final code = parts[0];
final message = parts[1];
displayError = '$code: $message';
}
}
setState(() {
_hasFailed = true;
_errorMessage = displayError;
});
}
}
@override
void dispose() {
_modeSub?.cancel();
_statusSub?.cancel();
_connectSub?.cancel();
_pulseController.dispose();
_waveController.dispose();
_glowController.dispose();
super.dispose();
}
Future<void> _endAndClose() async {
await widget.sdk.endSession();
if (!mounted) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final bool speaking = _mode == 'speaking';
final bool connected = _isConnected;
final bool failed = _hasFailed;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.2,
colors: [
failed
? const Color(0xFF2A1015)
: speaking
? const Color(0xFF1A1035)
: const Color(0xFF0F1A2E),
AppColors.background,
],
),
),
child: SafeArea(
child: Column(
children: [
_buildTopBar(connected, failed),
const Spacer(),
_buildMainVisual(speaking, failed),
const SizedBox(height: 48),
_buildStatusText(speaking),
const Spacer(),
_buildBottomControls(),
],
),
),
),
);
}
Widget _buildTopBar(bool connected, bool failed) {
final Color statusColor = failed
? AppColors.error
: connected
? AppColors.success
: AppColors.warning;
final String statusText = failed
? 'Failed'
: connected
? 'Connected'
: 'Connecting...';
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: statusColor.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(
color: statusColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
const Spacer(),
IconButton(
onPressed: _endAndClose,
icon: const Icon(Icons.close_rounded),
color: AppColors.textSecondary,
),
],
),
);
}
Widget _buildMainVisual(bool speaking, bool failed) {
return SizedBox(
height: 280,
child: AnimatedBuilder(
animation: Listenable.merge([
_pulseController,
_waveController,
_glowController,
]),
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
..._buildGlowRings(speaking, failed),
_buildMainOrb(speaking, failed),
if (!speaking && !failed) _buildWaveParticles(),
],
);
},
),
);
}
List<Widget> _buildGlowRings(bool speaking, bool failed) {
final double glowT = _glowController.value;
final Color ringColor = failed
? AppColors.error
: speaking
? AppColors.secondary
: AppColors.primary;
return List.generate(3, (index) {
final double scale = 1.0 + (index * 0.3) + (glowT * 0.1);
final double opacity = (0.3 - (index * 0.08)) * (1 - glowT * 0.3);
return Transform.scale(
scale: scale,
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: ringColor.withValues(alpha: opacity.clamp(0.05, 0.3)),
width: 2,
),
),
),
);
});
}
Widget _buildMainOrb(bool speaking, bool failed) {
final double pulseT = _pulseController.value;
final double scale = failed
? 0.95 + (pulseT * 0.05)
: speaking
? 1.0 + (pulseT * 0.08)
: 0.9 + (pulseT * 0.1);
final List<Color> gradientColors = failed
? [
AppColors.error,
const Color(0xFFDC2626),
const Color(0xFFB91C1C),
]
: speaking
? [
AppColors.secondary,
AppColors.primary,
AppColors.primaryDark,
]
: [
AppColors.accent,
AppColors.primary,
AppColors.primaryDark,
];
final Color shadowColor = failed
? AppColors.error
: speaking
? AppColors.secondary
: AppColors.primary;
final IconData icon = failed
? Icons.error_outline_rounded
: speaking
? Icons.volume_up_rounded
: Icons.mic_rounded;
return Transform.scale(
scale: scale,
child: Container(
width: 160,
height: 160,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: gradientColors),
boxShadow: [
BoxShadow(
color: shadowColor.withValues(alpha: 0.4 + (pulseT * 0.2)),
blurRadius: 40 + (pulseT * 20),
spreadRadius: 5,
),
],
),
child: Center(
child: Icon(
icon,
color: Colors.white.withValues(alpha: 0.9),
size: 48,
),
),
),
);
}
Widget _buildWaveParticles() {
final double waveT = _waveController.value;
return SizedBox(
width: 280,
height: 280,
child: Stack(
alignment: Alignment.center,
children: List.generate(8, (index) {
final double angle =
(index / 8) * 2 * math.pi + (waveT * 2 * math.pi);
final double radius =
110 + math.sin(waveT * 2 * math.pi + index) * 10;
final double x = math.cos(angle) * radius;
final double y = math.sin(angle) * radius;
final double size =
8 + math.sin(waveT * 2 * math.pi + index * 0.5) * 4;
return Transform.translate(
offset: Offset(x, y),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.accent.withValues(alpha: 0.6),
boxShadow: [
BoxShadow(
color: AppColors.accent.withValues(alpha: 0.4),
blurRadius: 8,
),
],
),
),
);
}),
),
);
}
Widget _buildStatusText(bool speaking) {
final bool failed = _hasFailed;
final bool connecting = !_isConnected && !_hasFailed;
return Column(
children: [
Text(
failed
? 'Connection Failed'
: connecting
? 'Connecting...'
: speaking
? 'Agent Speaking'
: 'Listening',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: failed ? AppColors.error : AppColors.textPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Text(
failed
? 'Could not connect to agent'
: connecting
? 'Please wait...'
: speaking
? 'Please wait for response...'
: 'Say something to begin',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.error.withValues(alpha: 0.3)),
),
child: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.error, fontSize: 12),
),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: () {
setState(() {
_errorMessage = null;
_hasFailed = false;
_isConnected = false;
});
_startSessionAndEnableVoice();
},
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Retry'),
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
),
),
],
],
);
}
Widget _buildBottomControls() {
return Padding(
padding: const EdgeInsets.only(bottom: 48),
child: GestureDetector(
onTap: _endAndClose,
child: Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFF6B6B), AppColors.error],
),
boxShadow: [
BoxShadow(
color: AppColors.error.withValues(alpha: 0.4),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: const Icon(
Icons.call_end_rounded,
color: Colors.white,
size: 32,
),
),
),
);
}
}