elevenlabs_flutter_sdk 0.0.1 copy "elevenlabs_flutter_sdk: ^0.0.1" to clipboard
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,
          ),
        ),
      ),
    );
  }
}
7
likes
140
points
29
downloads

Publisher

verified publishercohesyn.in

Weekly Downloads

Flutter plugin for ElevenLabs conversational agents on Android and iOS using a unified Dart API.

Homepage

Documentation

Documentation
API reference

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on elevenlabs_flutter_sdk

Packages that implement elevenlabs_flutter_sdk