Skip to main content

flutter_deepgram 1.0.1 copy "flutter_deepgram: ^1.0.1" to clipboard
flutter_deepgram: ^1.0.1 copied to clipboard

Deepgram SDK for Dart & Flutter - STT (pre-recorded, live, Flux), TTS (Aura, Flux), Voice Agent and Text Intelligence with resilient auto-reconnecting WebSockets.

example/lib/main.dart

/// Example app for the `flutter_deepgram` SDK.
///
/// This single-file demo exercises every major API surface of the package
/// across four tabs:
///
/// 1. **Prerecorded** — transcription of a public URL through
///    `POST /v1/listen` with smart formatting, diarization, entity
///    detection, sentiment, and summarization, plus plain-text analysis
///    through `POST /v1/read` (the text-intelligence endpoint).
/// 2. **Live STT** — a `/v1/listen` WebSocket session driven end to end:
///    connect, stream synthetic audio, finalize, close, with every typed
///    event stream (results, utterance ends, speech started, metadata,
///    errors, connection states) wired into the UI.
/// 3. **Text to Speech** — `POST /v1/speak` (Aura / Aura-2 voices) and
///    `POST /v2/speak` (Flux batch), returning complete audio bytes.
/// 4. **Voice Agent** — a `/v1/agent/converse` session built from a typed
///    VoiceAgentSettings object, with conversation bubbles, agent
///    thinking/speaking status, and automatic client-side function-call
///    responses.
///
/// The app deliberately avoids microphone and speaker plugins: tabs 2 and 4
/// stream synthetic silence so the streaming-session mechanics can be
/// demonstrated on any platform (including the web) without audio
/// permissions or native builds.
library;

import 'dart:async';
import 'dart:convert';

import 'package:flutter_deepgram/flutter_deepgram.dart';
import 'package:flutter/material.dart';

/// API key fallback supplied at launch time via
/// `flutter run --dart-define=DEEPGRAM_API_KEY=your_key`.
const String kApiKey = String.fromEnvironment('DEEPGRAM_API_KEY');

void main() {
  runApp(const DeepgramDemoApp());
}

/// Root of the demo application.
class DeepgramDemoApp extends StatelessWidget {
  /// Creates the app shell.
  const DeepgramDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'flutter_deepgram demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.deepPurple,
      ),
      home: const DemoHome(),
    );
  }
}

/// Holds the shared [DeepgramClient] and hosts the four demo tabs.
///
/// Every tab receives a `clientGetter` closure (a tear-off of
/// [_DemoHomeState._client]) so it always reads the current client at
/// button-press time; the shared API-key field and the "Initialize client"
/// button live in the header above the tab views.
class DemoHome extends StatefulWidget {
  /// Creates the demo home screen.
  const DemoHome({super.key});

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> {
  late final TextEditingController _keyController = TextEditingController(
    text: kApiKey,
  );

  /// The shared SDK client, created by the "Initialize client" button.
  DeepgramClient? _deepgram;

  /// The four tab bodies. Built once so their StatefulWidget states (and any
  /// running streaming sessions) survive key-bar rebuilds.
  late final List<Widget> _tabs = <Widget>[
    _PrerecordedTab(clientGetter: _client),
    _LiveTab(clientGetter: _client),
    _TtsTab(clientGetter: _client),
    _AgentTab(clientGetter: _client),
  ];

  @override
  void dispose() {
    _keyController.dispose();
    _deepgram?.close();
    super.dispose();
  }

  /// Callback handed to every tab: returns the current shared client.
  DeepgramClient? _client() => _deepgram;

  /// Builds the shared client from the key field.
  Future<void> _initializeClient() async {
    final String key = _keyController.text.trim();
    if (key.isEmpty) {
      _showSnackBar(
        context,
        'Enter a Deepgram API key first (or relaunch with '
        '--dart-define=DEEPGRAM_API_KEY=your_key).',
        isError: true,
      );
      return;
    }
    final DeepgramClient? previous = _deepgram;
    if (previous != null) {
      // Closing the old client retires its HTTP engine; running sessions
      // created from it are unaffected (they own their sockets).
      previous.close();
    }
    try {
      final DeepgramClient client = DeepgramClient(apiKey: key);
      setState(() => _deepgram = client);
      _showSnackBar(context, 'DeepgramClient initialized.');
    } catch (error) {
      _showSnackBar(context, 'Initialization failed: $error', isError: true);
    }
  }

  @override
  Widget build(BuildContext context) {
    final bool ready = _deepgram != null;
    return DefaultTabController(
      length: 4,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('flutter_deepgram demo'),
          bottom: const TabBar(
            isScrollable: true,
            tabs: <Widget>[
              Tab(text: 'Prerecorded'),
              Tab(text: 'Live STT'),
              Tab(text: 'Text to Speech'),
              Tab(text: 'Voice Agent'),
            ],
          ),
        ),
        body: Column(
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  TextField(
                    controller: _keyController,
                    keyboardType: TextInputType.url,
                    decoration: const InputDecoration(
                      isDense: true,
                      border: OutlineInputBorder(),
                      labelText: 'Deepgram API key',
                      helperText:
                          'Or launch with --dart-define=DEEPGRAM_API_KEY=your_key',
                    ),
                  ),
                  const SizedBox(height: 8),
                  Row(
                    children: <Widget>[
                      FilledButton.icon(
                        onPressed: _initializeClient,
                        icon: const Icon(Icons.vpn_key_outlined),
                        label: const Text('Initialize client'),
                      ),
                      const SizedBox(width: 8),
                      Icon(
                        ready ? Icons.check_circle : Icons.radio_button_off,
                        size: 14,
                        color: ready ? Colors.green : Colors.grey,
                      ),
                      const SizedBox(width: 4),
                      Expanded(
                        child: Text(
                          ready
                              ? 'Client ready — all tabs share this client.'
                              : 'No client yet — initialize before using the tabs.',
                          style: Theme.of(context).textTheme.bodySmall,
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            Expanded(child: TabBarView(children: _tabs)),
          ],
        ),
      ),
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tab 1 — Prerecorded transcription + text intelligence
// ─────────────────────────────────────────────────────────────────────────────

/// Demonstrates `deepgram.prerecorded.transcribeFromUrl` and
/// `deepgram.intelligence.analyze`.
class _PrerecordedTab extends StatefulWidget {
  /// Creates the prerecorded demo tab.
  const _PrerecordedTab({required this.clientGetter});

  /// Returns the shared client created in the header.
  final DeepgramClient? Function() clientGetter;

  @override
  State<_PrerecordedTab> createState() => _PrerecordedTabState();
}

class _PrerecordedTabState extends State<_PrerecordedTab>
    with AutomaticKeepAliveClientMixin {
  final TextEditingController _urlController = TextEditingController(
    text:
        'https://static.deepgram.com/examples/Bueller-Life-moves-pretty-fast.wav',
  );

  final TextEditingController _readController = TextEditingController(
    text: "Deepgram's new Dart SDK is a joy to use. The live transcription is "
        'fast and accurate, and the support team is responsive. We plan to '
        'ship it in our customer support product next week.',
  );

  bool _smartFormat = true;
  bool _diarize = true;
  bool _detectEntities = true;
  bool _sentiment = true;
  bool _summarize = true;
  bool _loading = false;
  bool _reading = false;

  PrerecordedTranscriptionResult? _result;
  TextIntelligenceResult? _readResult;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _urlController.dispose();
    _readController.dispose();
    super.dispose();
  }

  /// First alternative of the first channel, or null.
  SttAlternative? get _firstAlternative {
    final PrerecordedTranscriptionResult? result = _result;
    if (result == null || result.channels.isEmpty) {
      return null;
    }
    final List<SttAlternative> alternatives =
        result.channels.first.alternatives;
    return alternatives.isEmpty ? null : alternatives.first;
  }

  String get _transcript => _firstAlternative?.transcript ?? '';

  List<SttEntity> get _entities =>
      _firstAlternative?.entities ?? const <SttEntity>[];

  List<SttSentiment> get _sentiments =>
      _firstAlternative?.sentiments ??
      _result?.sentiments ??
      const <SttSentiment>[];

  List<String> get _summaries {
    final PrerecordedTranscriptionResult? result = _result;
    if (result == null) {
      return const <String>[];
    }
    final String? top = result.summary;
    final List<SttSummary>? fromAlternative = _firstAlternative?.summaries;
    return <String>[
      if (top != null && top.isNotEmpty) top,
      if (fromAlternative != null)
        for (final SttSummary summary in fromAlternative) summary.summary,
    ];
  }

  /// Transcribes the URL with the toggled features.
  Future<void> _transcribe() async {
    final DeepgramClient? deepgram = widget.clientGetter();
    if (deepgram == null) {
      _showSnackBar(
        context,
        'Initialize the client in the header first.',
        isError: true,
      );
      return;
    }
    final String url = _urlController.text.trim();
    if (url.isEmpty) {
      _showSnackBar(context, 'Enter an audio URL first.', isError: true);
      return;
    }
    setState(() => _loading = true);
    try {
      final PrerecordedTranscriptionResult result =
          await deepgram.prerecorded.transcribeFromUrl(
        url,
        options: PrerecordedOptions(
          model: DeepgramSttModels.nova3,
          smartFormat: _smartFormat,
          diarize: _diarize,
          detectEntities: _detectEntities,
          sentiment: _sentiment,
          summarize: _summarize
              ? const SummarizeOptions(v2: true, mode: SummarizeMode.paragraphs)
              : null,
        ),
      );
      if (!mounted) {
        return;
      }
      setState(() => _result = result);
    } catch (error) {
      if (mounted) {
        _showSnackBar(
          context,
          'Transcription failed: $error',
          isError: true,
        );
      }
    } finally {
      if (mounted) {
        setState(() => _loading = false);
      }
    }
  }

  /// Runs the /v1/read text-intelligence analysis.
  Future<void> _analyze() async {
    final DeepgramClient? deepgram = widget.clientGetter();
    if (deepgram == null) {
      _showSnackBar(
        context,
        'Initialize the client in the header first.',
        isError: true,
      );
      return;
    }
    final String text = _readController.text.trim();
    if (text.isEmpty) {
      _showSnackBar(context, 'Enter some text to analyze.', isError: true);
      return;
    }
    setState(() => _reading = true);
    try {
      final TextIntelligenceResult result = await deepgram.intelligence.analyze(
        text,
        options: const TextIntelligenceOptions(
          sentiment: true,
          topics: true,
          summarize: true,
          intents: true,
        ),
      );
      if (!mounted) {
        return;
      }
      setState(() => _readResult = result);
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Analysis failed: $error', isError: true);
      }
    } finally {
      if (mounted) {
        setState(() => _reading = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final TextStyle? labelStyle = Theme.of(context).textTheme.labelSmall;
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        _SectionHeader('Transcribe audio from a URL (POST /v1/listen)'),
        TextField(
          controller: _urlController,
          keyboardType: TextInputType.url,
          decoration: const InputDecoration(
            border: OutlineInputBorder(),
            labelText: 'Audio URL',
            helperText: 'Deepgram fetches the media for you.',
          ),
        ),
        const SizedBox(height: 4),
        Card(
          margin: EdgeInsets.zero,
          child: Column(
            children: <Widget>[
              SwitchListTile(
                dense: true,
                title: const Text('smart_format'),
                subtitle: const Text('Punctuation, capitalization, numbers.'),
                value: _smartFormat,
                onChanged: (bool value) => setState(() => _smartFormat = value),
              ),
              SwitchListTile(
                dense: true,
                title: const Text('diarize'),
                subtitle: const Text('Recognize speaker changes.'),
                value: _diarize,
                onChanged: (bool value) => setState(() => _diarize = value),
              ),
              SwitchListTile(
                dense: true,
                title: const Text('detect_entities'),
                subtitle:
                    const Text('Extract labeled values (people, places…).'),
                value: _detectEntities,
                onChanged: (bool value) =>
                    setState(() => _detectEntities = value),
              ),
              SwitchListTile(
                dense: true,
                title: const Text('sentiment'),
                subtitle: const Text('Sentiment per segment.'),
                value: _sentiment,
                onChanged: (bool value) => setState(() => _sentiment = value),
              ),
              SwitchListTile(
                dense: true,
                title: const Text('summarize'),
                subtitle: const Text('v2 summarizer, paragraph mode.'),
                value: _summarize,
                onChanged: (bool value) => setState(() => _summarize = value),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        FilledButton.icon(
          onPressed: _loading ? null : _transcribe,
          icon: const Icon(Icons.record_voice_over),
          label: const Text('Transcribe'),
        ),
        if (_loading)
          const Padding(
            padding: EdgeInsets.only(top: 8),
            child: LinearProgressIndicator(minHeight: 3),
          ),
        ..._resultCards(labelStyle),
        _SectionHeader('Analyze text (POST /v1/read)'),
        TextField(
          controller: _readController,
          maxLines: 5,
          minLines: 3,
          decoration: const InputDecoration(
            border: OutlineInputBorder(),
            labelText: 'Text to analyze',
            helperText: 'sentiment, topics, summarize, intents.',
          ),
        ),
        const SizedBox(height: 12),
        FilledButton.icon(
          onPressed: _reading ? null : _analyze,
          icon: const Icon(Icons.psychology),
          label: const Text('Analyze text'),
        ),
        if (_reading)
          const Padding(
            padding: EdgeInsets.only(top: 8),
            child: LinearProgressIndicator(minHeight: 3),
          ),
        ..._readCards(labelStyle),
      ],
    );
  }

  /// Cards rendering the prerecorded transcription result.
  List<Widget> _resultCards(TextStyle? labelStyle) {
    final PrerecordedTranscriptionResult? result = _result;
    if (result == null) {
      return const <Widget>[];
    }
    final TextStyle? small = Theme.of(context).textTheme.bodySmall;
    return <Widget>[
      _SectionHeader('Transcription result'),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Transcript', style: labelStyle),
              const SizedBox(height: 4),
              SelectableText(
                _transcript.isEmpty ? '(empty)' : _transcript,
              ),
              const SizedBox(height: 4),
              Text(
                'duration ${result.metadata.duration?.toStringAsFixed(1) ?? '?'} s'
                ' · request ${result.metadata.requestId ?? 'n/a'}',
                style: small,
              ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 8),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Detected entities (label: value)', style: labelStyle),
              const SizedBox(height: 6),
              if (_entities.isEmpty)
                const Text('None detected.')
              else
                Wrap(
                  spacing: 6,
                  runSpacing: 6,
                  children: <Widget>[
                    for (final SttEntity entity in _entities)
                      Chip(label: Text('${entity.label}: ${entity.value}')),
                  ],
                ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 8),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Sentiment per segment', style: labelStyle),
              if (_sentiments.isEmpty)
                const Padding(
                  padding: EdgeInsets.only(top: 6),
                  child: Text('None detected.'),
                )
              else
                for (final SttSentiment sentiment in _sentiments)
                  ListTile(
                    dense: true,
                    contentPadding: EdgeInsets.zero,
                    leading: _sentimentIcon(sentiment.sentiment),
                    title: Text(
                      sentiment.text.isEmpty ? '(no text)' : sentiment.text,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                    ),
                    trailing: Text(
                      '${sentiment.sentiment} '
                      '${sentiment.sentimentScore?.toStringAsFixed(2) ?? ''}',
                      style: small,
                    ),
                  ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 8),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Summary', style: labelStyle),
              if (_summaries.isEmpty)
                const Padding(
                  padding: EdgeInsets.only(top: 6),
                  child: Text('None returned.'),
                )
              else
                for (final String summary in _summaries)
                  Padding(
                    padding: const EdgeInsets.only(top: 6),
                    child: SelectableText(summary),
                  ),
            ],
          ),
        ),
      ),
    ];
  }

  /// Cards rendering the /v1/read analysis result.
  List<Widget> _readCards(TextStyle? labelStyle) {
    final TextIntelligenceResult? result = _readResult;
    if (result == null) {
      return const <Widget>[];
    }
    final TextStyle? small = Theme.of(context).textTheme.bodySmall;
    return <Widget>[
      _SectionHeader('Text intelligence result'),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Average sentiment', style: labelStyle),
              const SizedBox(height: 4),
              Text(
                '${result.averageSentiment ?? 'n/a'} '
                '(${result.averageSentimentScore?.toStringAsFixed(2) ?? '—'})',
              ),
              const SizedBox(height: 8),
              Text('Sentiment segments', style: labelStyle),
              for (final TextSentiment sentiment in result.sentiments)
                ListTile(
                  dense: true,
                  contentPadding: EdgeInsets.zero,
                  leading: _sentimentIcon(sentiment.sentiment),
                  title: Text(
                    sentiment.text,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                  ),
                  trailing: Text(
                    '${sentiment.sentiment} '
                    '${sentiment.sentimentScore?.toStringAsFixed(2) ?? ''}',
                    style: small,
                  ),
                ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 8),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Topics', style: labelStyle),
              const SizedBox(height: 6),
              if (result.topics.isEmpty)
                const Text('None detected.')
              else
                Wrap(
                  spacing: 6,
                  runSpacing: 6,
                  children: <Widget>[
                    for (final TextTopic topic in result.topics)
                      Chip(
                        label: Text(
                          topic.confidenceScore == null
                              ? topic.topic
                              : '${topic.topic} '
                                  '(${topic.confidenceScore!.toStringAsFixed(2)})',
                        ),
                      ),
                  ],
                ),
              const SizedBox(height: 8),
              Text('Intents', style: labelStyle),
              const SizedBox(height: 6),
              if (result.intents.isEmpty)
                const Text('None detected.')
              else
                Wrap(
                  spacing: 6,
                  runSpacing: 6,
                  children: <Widget>[
                    for (final TextIntent intent in result.intents)
                      Chip(
                        label: Text(
                          intent.confidenceScore == null
                              ? intent.intent
                              : '${intent.intent} '
                                  '(${intent.confidenceScore!.toStringAsFixed(2)})',
                        ),
                      ),
                  ],
                ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 8),
      Card(
        margin: EdgeInsets.zero,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Summary', style: labelStyle),
              if (result.summaries.isEmpty)
                const Padding(
                  padding: EdgeInsets.only(top: 6),
                  child: Text('None returned.'),
                )
              else
                for (final TextSummary summary in result.summaries)
                  Padding(
                    padding: const EdgeInsets.only(top: 6),
                    child: SelectableText(summary.summary),
                  ),
            ],
          ),
        ),
      ),
    ];
  }

  Widget _sentimentIcon(String sentiment) {
    switch (sentiment) {
      case 'positive':
        return const Icon(Icons.sentiment_satisfied, color: Colors.green);
      case 'negative':
        return const Icon(Icons.sentiment_dissatisfied, color: Colors.red);
      default:
        return const Icon(Icons.sentiment_neutral, color: Colors.blueGrey);
    }
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tab 2 — Live streaming transcription
// ─────────────────────────────────────────────────────────────────────────────

/// Demonstrates a full `DeepgramLiveClient` session lifecycle with synthetic
/// audio, so no microphone plugin is needed.
class _LiveTab extends StatefulWidget {
  /// Creates the live transcription demo tab.
  const _LiveTab({required this.clientGetter});

  /// Returns the shared client created in the header.
  final DeepgramClient? Function() clientGetter;

  @override
  State<_LiveTab> createState() => _LiveTabState();
}

class _LiveTabState extends State<_LiveTab> with AutomaticKeepAliveClientMixin {
  DeepgramLiveClient? _live;
  WsConnectionState _state = WsConnectionState.idle;
  String _finalTranscript = '';
  String _interim = '';
  int _sentBytes = 0;
  bool _sending = false;
  final List<String> _events = <String>[];
  final List<StreamSubscription<dynamic>> _subs =
      <StreamSubscription<dynamic>>[];

  bool get _isRunning => _live != null;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _cancelSubscriptions();
    final DeepgramLiveClient? live = _live;
    if (live != null) {
      unawaited(live.close());
    }
    super.dispose();
  }

  void _cancelSubscriptions() {
    for (final StreamSubscription<dynamic> sub in _subs) {
      unawaited(sub.cancel());
    }
    _subs.clear();
  }

  /// Prepends a timestamped entry to the in-app event log.
  void _log(String message) {
    if (!mounted) {
      return;
    }
    final DateTime now = DateTime.now();
    final String stamp = '${now.hour.toString().padLeft(2, '0')}:'
        '${now.minute.toString().padLeft(2, '0')}:'
        '${now.second.toString().padLeft(2, '0')}';
    setState(() {
      _events.insert(0, '$stamp  $message');
      if (_events.length > 200) {
        _events.removeLast();
      }
    });
  }

  /// Connects a new live session and wires every typed event stream.
  Future<void> _startSession() async {
    final DeepgramClient? deepgram = widget.clientGetter();
    if (deepgram == null) {
      _showSnackBar(
        context,
        'Initialize the client in the header first.',
        isError: true,
      );
      return;
    }
    if (_live != null) {
      _showSnackBar(
        context,
        'A session is already running — stop it first.',
        isError: true,
      );
      return;
    }
    setState(() => _state = WsConnectionState.connecting);
    final DeepgramLiveClient live;
    try {
      live = deepgram.createLiveClient(
        options: const LiveTranscriptionOptions(
          model: 'nova-3',
          encoding: LiveEncoding.linear16,
          sampleRate: 16000,
          interimResults: true,
          smartFormat: true,
          punctuate: true,
          vadEvents: true,
          utteranceEndMs: 1000,
          endpointingMs: 300,
        ),
      );
    } catch (error) {
      if (!mounted) {
        return;
      }
      setState(() => _state = WsConnectionState.idle);
      _showSnackBar(
        context,
        'Could not create the live client: $error',
        isError: true,
      );
      return;
    }
    _wire(live);
    setState(() => _live = live);
    try {
      await live.start();
      _log('Session started: nova-3, linear16 @ 16 kHz, vad_events on.');
    } catch (error) {
      try {
        await live.close();
      } catch (_) {
        // The session never fully opened; ignore teardown errors.
      }
      _cancelSubscriptions();
      if (!mounted) {
        return;
      }
      setState(() {
        _live = null;
        _state = WsConnectionState.failed;
      });
      _showSnackBar(
        context,
        'Live session failed to start: $error',
        isError: true,
      );
    }
  }

  void _wire(DeepgramLiveClient live) {
    _subs.add(
      live.connectionStates.listen((WsConnectionState state) {
        if (mounted) {
          setState(() => _state = state);
        }
      }),
    );
    _subs.add(
      live.results.listen((LiveTranscriptionResult result) {
        if (!mounted) {
          return;
        }
        setState(() {
          if (result.isFinal) {
            if (result.transcript.isNotEmpty) {
              if (_finalTranscript.isNotEmpty) {
                _finalTranscript += ' ';
              }
              _finalTranscript += result.transcript;
            }
            _interim = '';
          } else {
            _interim = result.transcript;
          }
        });
      }),
    );
    _subs.add(
      live.speechStarted.listen((LiveSpeechStarted event) {
        _log('SpeechStarted @ ${event.timestamp}s '
            '(channel ${event.channel.join(', ')}).');
      }),
    );
    _subs.add(
      live.utteranceEnds.listen((LiveUtteranceEnd event) {
        _log('UtteranceEnd — last word ended at ${event.lastWordEnd}s.');
      }),
    );
    _subs.add(
      live.metadata.listen((LiveMetadataMessage event) {
        _log('Metadata: request ${event.requestId}, ${event.duration}s '
            'across ${event.channels} channel(s).');
      }),
    );
    _subs.add(
      live.errors.listen((LiveErrorMessage event) {
        _log('Error (${event.variant}): ${event.description}');
      }),
    );
  }

  /// Streams 50 chunks of 100 ms silence (3200 zero bytes each, i.e. 16 kHz
  /// 16-bit mono) to demonstrate `sendAudio` pacing without a microphone.
  Future<void> _sendSyntheticAudio() async {
    final DeepgramLiveClient? live = _live;
    if (live == null) {
      _showSnackBar(context, 'Start the session first.', isError: true);
      return;
    }
    if (!live.isConnected) {
      _showSnackBar(context, 'The socket is not open yet.', isError: true);
      return;
    }
    setState(() => _sending = true);
    try {
      final List<int> chunk = List<int>.filled(3200, 0);
      for (var i = 0; i < 50; i++) {
        live.sendAudio(chunk);
        if (mounted) {
          setState(() => _sentBytes += chunk.length);
        }
        await Future<void>.delayed(const Duration(milliseconds: 100));
      }
      _log('Streamed 50 × 3200 bytes (≈5 s of synthetic silence).');
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Sending failed: $error', isError: true);
      }
    } finally {
      if (mounted) {
        setState(() => _sending = false);
      }
    }
  }

  /// Flushes pending results without closing the stream.
  Future<void> _finalize() async {
    final DeepgramLiveClient? live = _live;
    if (live == null) {
      return;
    }
    try {
      await live.finish();
      _log('Sent Finalize — pending results flush without closing.');
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Finalize failed: $error', isError: true);
      }
    }
  }

  /// Gracefully closes the session.
  Future<void> _stopSession() async {
    final DeepgramLiveClient? live = _live;
    if (live == null) {
      return;
    }
    try {
      await live.close();
      _log('Session closed.');
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Close failed: $error', isError: true);
      }
    } finally {
      _cancelSubscriptions();
      if (mounted) {
        setState(() {
          _live = null;
          _state = WsConnectionState.closed;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final TextStyle? small = Theme.of(context).textTheme.bodySmall;
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        _SectionHeader('Live transcription (WSS /v1/listen)'),
        Row(
          children: <Widget>[
            _StatusChip(_state),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                '${_formatBytes(_sentBytes)} sent · '
                'reconnect attempts: ${_live?.reconnectAttempts ?? 0}',
                style: small,
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton.icon(
              onPressed: _isRunning || _sending ? null : _startSession,
              icon: const Icon(Icons.play_arrow),
              label: const Text('Start'),
            ),
            OutlinedButton.icon(
              onPressed: !_isRunning || _sending ? null : _sendSyntheticAudio,
              icon: const Icon(Icons.graphic_eq),
              label: const Text('Send synthetic audio'),
            ),
            OutlinedButton.icon(
              onPressed: !_isRunning ? null : _finalize,
              icon: const Icon(Icons.done_all),
              label: const Text('Finalize'),
            ),
            OutlinedButton.icon(
              onPressed: !_isRunning ? null : _stopSession,
              icon: const Icon(Icons.stop),
              label: const Text('Stop'),
            ),
          ],
        ),
        _NoteText(
          'No microphone plugin is used: "Send synthetic audio" streams 50 '
          'chunks of 100 ms zeroed linear16 samples through sendAudio(). '
          'Swap in real PCM from your audio stack for production use.',
        ),
        _SectionHeader('Transcript'),
        Card(
          margin: EdgeInsets.zero,
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                SelectableText(
                  _finalTranscript.isEmpty
                      ? '(no final results yet)'
                      : _finalTranscript,
                ),
                const SizedBox(height: 4),
                Text(
                  _interim,
                  style: TextStyle(
                    fontStyle: FontStyle.italic,
                    color: Colors.blueGrey.shade600,
                  ),
                ),
              ],
            ),
          ),
        ),
        const SizedBox(height: 16),
        _SectionHeader('Events (UtteranceEnd / SpeechStarted / Metadata …)'),
        _EventLog(entries: _events),
      ],
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tab 3 — Text to speech
// ─────────────────────────────────────────────────────────────────────────────

/// Demonstrates `deepgram.tts.speak` (Aura / Aura-2) and
/// `deepgram.tts.speakFlux` (Flux batch).
class _TtsTab extends StatefulWidget {
  /// Creates the text-to-speech demo tab.
  const _TtsTab({required this.clientGetter});

  /// Returns the shared client created in the header.
  final DeepgramClient? Function() clientGetter;

  @override
  State<_TtsTab> createState() => _TtsTabState();
}

class _TtsTabState extends State<_TtsTab> with AutomaticKeepAliveClientMixin {
  final TextEditingController _textController = TextEditingController(
    text: 'Hello! This audio was synthesized by the flutter_deepgram SDK.',
  );

  String _voice = DeepgramVoices.auraAsteriaEn;
  TtsEncoding _encoding = TtsEncoding.linear16;
  bool _speaking = false;
  bool _speakingFlux = false;
  String? _speakSummary;
  String? _fluxSummary;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  /// Requests Aura / Aura-2 audio and reports byte count and latency.
  Future<void> _speak() async {
    final DeepgramClient? deepgram = widget.clientGetter();
    if (deepgram == null) {
      _showSnackBar(
        context,
        'Initialize the client in the header first.',
        isError: true,
      );
      return;
    }
    final String text = _textController.text.trim();
    if (text.isEmpty) {
      _showSnackBar(context, 'Enter some text first.', isError: true);
      return;
    }
    setState(() => _speaking = true);
    final Stopwatch watch = Stopwatch()..start();
    try {
      final List<int> bytes = await deepgram.tts.speak(
        text,
        options: SpeakOptions(
          model: _voice,
          encoding: _encoding,
          container: TtsContainer.wav,
          sampleRate: 24000,
        ),
      );
      watch.stop();
      if (!mounted) {
        return;
      }
      setState(() {
        _speakSummary = 'Received ${_formatBytes(bytes.length)} of '
            '${_encoding.wireName}/wav audio in ${watch.elapsedMilliseconds} '
            'ms — the bytes are ready for an audio player.';
      });
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'TTS failed: $error', isError: true);
      }
    } finally {
      if (mounted) {
        setState(() => _speaking = false);
      }
    }
  }

  /// Requests Flux TTS audio through the /v2/speak batch endpoint.
  Future<void> _speakFlux() async {
    final DeepgramClient? deepgram = widget.clientGetter();
    if (deepgram == null) {
      _showSnackBar(
        context,
        'Initialize the client in the header first.',
        isError: true,
      );
      return;
    }
    final String text = _textController.text.trim();
    if (text.isEmpty) {
      _showSnackBar(context, 'Enter some text first.', isError: true);
      return;
    }
    setState(() => _speakingFlux = true);
    final Stopwatch watch = Stopwatch()..start();
    try {
      final List<int> bytes = await deepgram.tts.speakFlux(
        text,
        options: const FluxTtsOptions(
          model: DeepgramVoices.fluxAlexisEn,
          expressivity: 1,
        ),
      );
      watch.stop();
      if (!mounted) {
        return;
      }
      setState(() {
        _fluxSummary = 'Received ${_formatBytes(bytes.length)} of Flux audio '
            'in ${watch.elapsedMilliseconds} ms — ready for an audio player.';
      });
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Flux TTS failed: $error', isError: true);
      }
    } finally {
      if (mounted) {
        setState(() => _speakingFlux = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        _SectionHeader('Aura / Aura-2 voices (POST /v1/speak)'),
        TextField(
          controller: _textController,
          maxLines: 3,
          decoration: const InputDecoration(
            border: OutlineInputBorder(),
            labelText: 'Text to speak',
            helperText: 'Up to 2000 characters per request.',
          ),
        ),
        const SizedBox(height: 12),
        Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Expanded(
              child: DropdownButtonFormField<String>(
                initialValue: _voice,
                decoration: InputDecoration(
                  border: const OutlineInputBorder(),
                  labelText: 'Voice (first 20 of '
                      '${DeepgramVoices.allV1.length} /v1/speak voices)',
                ),
                items: <DropdownMenuItem<String>>[
                  for (final String voice in DeepgramVoices.allV1.take(20))
                    DropdownMenuItem<String>(
                      value: voice,
                      child: Text(
                        voice,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                ],
                onChanged: (String? value) {
                  if (value != null) {
                    setState(() => _voice = value);
                  }
                },
              ),
            ),
            const SizedBox(width: 8),
            Expanded(
              child: DropdownButtonFormField<TtsEncoding>(
                initialValue: _encoding,
                decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: 'Encoding (wav container)',
                ),
                items: const <DropdownMenuItem<TtsEncoding>>[
                  DropdownMenuItem<TtsEncoding>(
                    value: TtsEncoding.linear16,
                    child: Text('linear16'),
                  ),
                  DropdownMenuItem<TtsEncoding>(
                    value: TtsEncoding.mulaw,
                    child: Text('mulaw'),
                  ),
                ],
                onChanged: (TtsEncoding? value) {
                  if (value != null) {
                    setState(() => _encoding = value);
                  }
                },
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        FilledButton.icon(
          onPressed: _speaking ? null : _speak,
          icon: const Icon(Icons.volume_up),
          label: const Text('Speak (container=wav, 24 kHz)'),
        ),
        if (_speaking)
          const Padding(
            padding: EdgeInsets.only(top: 8),
            child: LinearProgressIndicator(minHeight: 3),
          ),
        if (_speakSummary != null)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: Text(_speakSummary!),
          ),
        _NoteText(
          'speak() returns the complete audio as a List<int> (a WAV file in '
          'this configuration). Hand the bytes to an audio player such as '
          'just_audio, or write them to disk with dart:io on non-web '
          'platforms. The SDK also offers speakStream() for progressive '
          'playback and createTtsStreamClient() for the WebSocket endpoint.',
        ),
        _SectionHeader('Flux TTS, batch (POST /v2/speak)'),
        OutlinedButton.icon(
          onPressed: _speakingFlux ? null : _speakFlux,
          icon: const Icon(Icons.auto_awesome),
          label: const Text('Speak with flux-alexis-en (expressivity 1)'),
        ),
        if (_speakingFlux)
          const Padding(
            padding: EdgeInsets.only(top: 8),
            child: LinearProgressIndicator(minHeight: 3),
          ),
        if (_fluxSummary != null)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: Text(_fluxSummary!),
          ),
        _NoteText(
          'FluxTtsOptions(model: DeepgramVoices.fluxAlexisEn, expressivity: 1) '
          'hits the turn-based /v2/speak batch endpoint. For streaming Flux '
          'TTS with barge-in interrupts, use createFluxTtsClient().',
        ),
      ],
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Tab 4 — Voice agent
// ─────────────────────────────────────────────────────────────────────────────

/// Demonstrates a full Voice Agent session built from a typed
/// VoiceAgentSettings object, with automatic client-side function-call
/// responses and synthetic audio.
class _AgentTab extends StatefulWidget {
  /// Creates the voice agent demo tab.
  const _AgentTab({required this.clientGetter});

  /// Returns the shared client created in the header.
  final DeepgramClient? Function() clientGetter;

  @override
  State<_AgentTab> createState() => _AgentTabState();
}

class _AgentTabState extends State<_AgentTab>
    with AutomaticKeepAliveClientMixin {
  DeepgramVoiceAgent? _agent;
  WsConnectionState _state = WsConnectionState.idle;
  final List<AgentConversationText> _messages = <AgentConversationText>[];
  final List<String> _events = <String>[];
  String _status = 'Idle — start a session to begin.';
  int _audioBytes = 0;
  int _sentBytes = 0;
  bool _sending = false;
  final ScrollController _chatScroll = ScrollController();
  final List<StreamSubscription<dynamic>> _subs =
      <StreamSubscription<dynamic>>[];

  /// Pretty-printed JSON of the settings sent to the agent.
  late final String _settingsJson =
      const JsonEncoder.withIndent('  ').convert(_buildSettings().toJson());

  bool get _isRunning => _agent != null;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _cancelSubscriptions();
    final DeepgramVoiceAgent? agent = _agent;
    if (agent != null) {
      unawaited(agent.close());
    }
    _chatScroll.dispose();
    super.dispose();
  }

  void _cancelSubscriptions() {
    for (final StreamSubscription<dynamic> sub in _subs) {
      unawaited(sub.cancel());
    }
    _subs.clear();
  }

  /// The typed Settings object sent right after the server's Welcome frame.
  VoiceAgentSettings _buildSettings() {
    return VoiceAgentSettings(
      agent: AgentAgentSettings(
        greeting: 'Hello! I am your Deepgram voice agent.',
        listen: const AgentListen(
          provider: AgentListenProvider.v1(model: 'nova-3', language: 'en-US'),
        ),
        think: AgentThink(
          provider: AgentThinkProvider.openAi(model: 'gpt-4o-mini'),
          prompt: 'You are a friendly, concise weather assistant. Keep every '
              'answer under three sentences. When asked about the weather, '
              'call the get_weather function and report its result.',
          functions: const <AgentFunctionDefinition>[
            AgentFunctionDefinition.client(
              name: 'get_weather',
              description: 'Returns the current weather for a city.',
              parameters: <String, dynamic>{
                'type': 'object',
                'properties': <String, dynamic>{
                  'city': <String, dynamic>{
                    'type': 'string',
                    'description': 'The city to get the weather for.',
                  },
                },
                'required': <String>['city'],
              },
            ),
          ],
        ),
        speak: const AgentSpeak(
          provider: AgentSpeakProvider(model: 'aura-2-thalia-en'),
        ),
      ),
      audio: const AgentAudioSettings(
        input: AgentAudioInput(
          encoding: AgentAudioEncoding.linear16,
          sampleRate: 24000,
        ),
        output: AgentAudioOutput(
          encoding: AgentAudioOutputEncoding.linear16,
          sampleRate: 24000,
          container: AgentAudioOutputContainer.none,
        ),
      ),
    );
  }

  /// Prepends a timestamped entry to the in-app event log.
  void _log(String message) {
    if (!mounted) {
      return;
    }
    final DateTime now = DateTime.now();
    final String stamp = '${now.hour.toString().padLeft(2, '0')}:'
        '${now.minute.toString().padLeft(2, '0')}:'
        '${now.second.toString().padLeft(2, '0')}';
    setState(() {
      _events.insert(0, '$stamp  $message');
      if (_events.length > 200) {
        _events.removeLast();
      }
    });
  }

  /// Connects the agent, sends the Settings, and wires every typed stream.
  Future<void> _startAgent() async {
    final DeepgramClient? deepgram = widget.clientGetter();
    if (deepgram == null) {
      _showSnackBar(
        context,
        'Initialize the client in the header first.',
        isError: true,
      );
      return;
    }
    if (_agent != null) {
      _showSnackBar(
        context,
        'An agent session is already running — stop it first.',
        isError: true,
      );
      return;
    }
    setState(() {
      _state = WsConnectionState.connecting;
      _status = 'Connecting and negotiating Settings…';
    });
    final DeepgramVoiceAgent agent;
    try {
      agent = deepgram.createVoiceAgent();
    } catch (error) {
      if (!mounted) {
        return;
      }
      setState(() {
        _state = WsConnectionState.idle;
        _status = 'Idle — start a session to begin.';
      });
      _showSnackBar(
        context,
        'Could not create the voice agent: $error',
        isError: true,
      );
      return;
    }
    _wire(agent);
    setState(() => _agent = agent);
    try {
      await agent.start(_buildSettings());
      _log('Agent session established (SettingsApplied received).');
      if (mounted) {
        setState(() => _status = 'Session ready — the greeting is speaking.');
      }
    } catch (error) {
      try {
        await agent.close();
      } catch (_) {
        // The session never fully opened; ignore teardown errors.
      }
      _cancelSubscriptions();
      if (!mounted) {
        return;
      }
      setState(() {
        _agent = null;
        _state = WsConnectionState.failed;
        _status = 'Start failed.';
      });
      _showSnackBar(
        context,
        'Agent failed to start: $error',
        isError: true,
      );
    }
  }

  void _wire(DeepgramVoiceAgent agent) {
    _subs.add(
      agent.connectionStates.listen((WsConnectionState state) {
        if (mounted) {
          setState(() => _state = state);
        }
      }),
    );
    _subs.add(
      agent.welcome.listen((AgentWelcome event) {
        _log('Welcome: request ${event.requestId}.');
      }),
    );
    _subs.add(
      agent.settingsApplied.listen((_) {
        _log('SettingsApplied — the server accepted the configuration.');
      }),
    );
    _subs.add(
      agent.conversationText.listen((AgentConversationText event) {
        if (!mounted) {
          return;
        }
        setState(() {
          if (_messages.isNotEmpty && _messages.last.role == event.role) {
            final AgentConversationText last = _messages.removeLast();
            final String merged = last.content.isEmpty
                ? event.content
                : '${last.content} ${event.content}'.trim();
            _messages.add(
              AgentConversationText(role: event.role, content: merged),
            );
          } else {
            _messages.add(event);
          }
        });
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (_chatScroll.hasClients) {
            _chatScroll.jumpTo(_chatScroll.position.maxScrollExtent);
          }
        });
      }),
    );
    _subs.add(
      agent.userStartedSpeaking.listen((_) {
        _log('UserStartedSpeaking — barge-in; mute agent playback.');
      }),
    );
    _subs.add(
      agent.agentThinking.listen((AgentAgentThinking event) {
        if (mounted) {
          setState(
            () => _status = 'Agent thinking: ${_truncate(event.content, 120)}',
          );
        }
      }),
    );
    _subs.add(
      agent.agentStartedSpeaking.listen((AgentAgentStartedSpeaking event) {
        if (mounted) {
          setState(
            () => _status = 'Agent speaking (total ${event.totalLatency}s · '
                'tts ${event.ttsLatency}s · ttt ${event.tttLatency}s).',
          );
        }
      }),
    );
    _subs.add(
      agent.agentAudioDone.listen((_) {
        if (mounted) {
          setState(() => _status = 'Agent finished speaking.');
        }
      }),
    );
    _subs.add(
      agent.audio.listen((List<int> bytes) {
        if (mounted) {
          setState(() => _audioBytes += bytes.length);
        }
      }),
    );
    _subs.add(
      agent.functionCallRequests.listen((AgentFunctionCallRequest request) {
        for (final AgentRequestedFunction function in request.functions) {
          _log('FunctionCallRequest: ${function.name}(${function.arguments}) '
              '[id ${function.id}, client_side ${function.clientSide}].');
          const String cannedResponse = '{"temperature": 72, "unit": "F"}';
          agent.sendFunctionCallResponse(
            id: function.id,
            name: function.name,
            content: cannedResponse,
          );
          _log('Sent canned $cannedResponse back for ${function.name}.');
        }
      }),
    );
    _subs.add(
      agent.errors.listen((AgentErrorEvent event) {
        _log('Error (${event.code}): ${event.description}');
      }),
    );
    _subs.add(
      agent.warnings.listen((AgentWarningEvent event) {
        _log('Warning (${event.code}): ${event.description}');
      }),
    );
  }

  /// Streams 50 chunks of 100 ms silence (4800 zero bytes each, i.e. 24 kHz
  /// 16-bit mono, matching the configured audio.input).
  Future<void> _sendSyntheticAudio() async {
    final DeepgramVoiceAgent? agent = _agent;
    if (agent == null) {
      _showSnackBar(context, 'Start the agent first.', isError: true);
      return;
    }
    if (!agent.isConnected) {
      _showSnackBar(context, 'The socket is not open yet.', isError: true);
      return;
    }
    setState(() => _sending = true);
    try {
      final List<int> chunk = List<int>.filled(4800, 0);
      for (var i = 0; i < 50; i++) {
        agent.sendAudio(chunk);
        if (mounted) {
          setState(() => _sentBytes += chunk.length);
        }
        await Future<void>.delayed(const Duration(milliseconds: 100));
      }
      _log('Streamed 50 × 4800 bytes (≈5 s of synthetic silence).');
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Sending failed: $error', isError: true);
      }
    } finally {
      if (mounted) {
        setState(() => _sending = false);
      }
    }
  }

  /// Gracefully closes the agent session.
  Future<void> _stopAgent() async {
    final DeepgramVoiceAgent? agent = _agent;
    if (agent == null) {
      return;
    }
    try {
      await agent.close();
      _log('Agent session closed.');
    } catch (error) {
      if (mounted) {
        _showSnackBar(context, 'Close failed: $error', isError: true);
      }
    } finally {
      _cancelSubscriptions();
      if (mounted) {
        setState(() {
          _agent = null;
          _state = WsConnectionState.closed;
          _status = 'Session closed.';
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    final TextStyle? small = Theme.of(context).textTheme.bodySmall;
    return ListView(
      padding: const EdgeInsets.all(16),
      children: <Widget>[
        _SectionHeader('Voice agent (WSS /v1/agent/converse)'),
        Row(
          children: <Widget>[
            _StatusChip(_state),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                '${_formatBytes(_sentBytes)} sent · '
                '${_formatBytes(_audioBytes)} agent audio received',
                style: small,
              ),
            ),
          ],
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton.icon(
              onPressed: _isRunning ? null : _startAgent,
              icon: const Icon(Icons.play_arrow),
              label: const Text('Start'),
            ),
            OutlinedButton.icon(
              onPressed: !_isRunning || _sending ? null : _sendSyntheticAudio,
              icon: const Icon(Icons.graphic_eq),
              label: const Text('Send synthetic audio'),
            ),
            OutlinedButton.icon(
              onPressed: !_isRunning ? null : _stopAgent,
              icon: const Icon(Icons.stop),
              label: const Text('Stop'),
            ),
          ],
        ),
        _NoteText(
          'Uses the API key from the field at the top of the app. The think '
          '(LLM) provider executes inside Deepgram, so your Deepgram account '
          'must have the OpenAI provider enabled for gpt-4o-mini. The demo '
          'auto-replies to every get_weather FunctionCallRequest with '
          '{"temperature": 72, "unit": "F"}; agent audio bytes would be fed '
          'to a speaker in a production app.',
        ),
        const SizedBox(height: 8),
        Card(
          margin: EdgeInsets.zero,
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text('Agent status', style: small),
                const SizedBox(height: 4),
                Text(_status),
              ],
            ),
          ),
        ),
        _SectionHeader('Conversation'),
        Container(
          height: 220,
          decoration: BoxDecoration(
            border: Border.all(color: Theme.of(context).dividerColor),
            borderRadius: BorderRadius.circular(8),
          ),
          child: _messages.isEmpty
              ? const Center(
                  child: Text(
                    '(no conversation yet)',
                    style: TextStyle(
                      fontStyle: FontStyle.italic,
                      color: Colors.grey,
                    ),
                  ),
                )
              : ListView.builder(
                  controller: _chatScroll,
                  padding: const EdgeInsets.symmetric(vertical: 8),
                  itemCount: _messages.length,
                  itemBuilder: (BuildContext context, int index) {
                    final AgentConversationText message = _messages[index];
                    return _ChatBubble(
                      role: message.role,
                      content: message.content,
                    );
                  },
                ),
        ),
        _SectionHeader('Settings JSON (from the typed VoiceAgentSettings)'),
        Card(
          margin: EdgeInsets.zero,
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: SelectableText(
              _settingsJson,
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 11.5,
              ),
            ),
          ),
        ),
        const SizedBox(height: 16),
        _SectionHeader('Events (function calls · errors · warnings …)'),
        _EventLog(entries: _events),
      ],
    );
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Shared widgets and helpers
// ─────────────────────────────────────────────────────────────────────────────

/// A bold section title.
class _SectionHeader extends StatelessWidget {
  /// Creates a section header.
  const _SectionHeader(this.title);

  /// The header text.
  final String title;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(top: 16, bottom: 8),
      child: Text(
        title,
        style: Theme.of(
          context,
        ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
      ),
    );
  }
}

/// A small grey explanatory note.
class _NoteText extends StatelessWidget {
  /// Creates a note.
  const _NoteText(this.text);

  /// The note text.
  final String text;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(top: 8),
      child: Text(
        text,
        style: Theme.of(
          context,
        ).textTheme.bodySmall?.copyWith(color: Colors.blueGrey),
      ),
    );
  }
}

/// A colored chip visualizing a WebSocket connection state.
class _StatusChip extends StatelessWidget {
  /// Creates a status chip.
  const _StatusChip(this.state);

  /// The state to render.
  final WsConnectionState state;

  @override
  Widget build(BuildContext context) {
    final Color color = _stateColor(state);
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.12),
        borderRadius: BorderRadius.circular(999),
        border: Border.all(color: color),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Icon(Icons.circle, size: 8, color: color),
          const SizedBox(width: 6),
          Text(
            _stateLabel(state).toUpperCase(),
            style: TextStyle(
              color: color,
              fontSize: 12,
              fontWeight: FontWeight.bold,
            ),
          ),
        ],
      ),
    );
  }
}

/// A bordered, scrollable list of timestamped event entries.
class _EventLog extends StatelessWidget {
  /// Creates an event log. Newest entries are shown at the top.
  const _EventLog({required this.entries});

  /// The log entries (newest first).
  final List<String> entries;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 160,
      width: double.infinity,
      decoration: BoxDecoration(
        border: Border.all(color: Theme.of(context).dividerColor),
        borderRadius: BorderRadius.circular(8),
      ),
      child: entries.isEmpty
          ? const Center(
              child: Text(
                '(no events yet)',
                style: TextStyle(
                  fontStyle: FontStyle.italic,
                  color: Colors.grey,
                ),
              ),
            )
          : ListView.builder(
              padding: const EdgeInsets.symmetric(vertical: 6),
              itemCount: entries.length,
              itemBuilder: (BuildContext context, int index) {
                return Padding(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 8,
                    vertical: 1,
                  ),
                  child: Text(
                    entries[index],
                    style: const TextStyle(
                      fontFamily: 'monospace',
                      fontSize: 12,
                    ),
                  ),
                );
              },
            ),
    );
  }
}

/// A chat bubble for one conversation statement.
class _ChatBubble extends StatelessWidget {
  /// Creates a bubble. [role] is `'user'` or `'assistant'`.
  const _ChatBubble({required this.role, required this.content});

  /// Who spoke the statement.
  final String role;

  /// The statement text.
  final String content;

  bool get _isUser => role == 'user';

  @override
  Widget build(BuildContext context) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
    return Align(
      alignment: _isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4),
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        constraints: const BoxConstraints(maxWidth: 320),
        decoration: BoxDecoration(
          color: _isUser
              ? colorScheme.primaryContainer
              : colorScheme.secondaryContainer,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              _isUser ? 'User' : 'Agent',
              style: TextStyle(
                fontSize: 11,
                fontWeight: FontWeight.bold,
                color: _isUser
                    ? colorScheme.onPrimaryContainer
                    : colorScheme.onSecondaryContainer,
              ),
            ),
            const SizedBox(height: 2),
            Text(
              content,
              style: TextStyle(
                color: _isUser
                    ? colorScheme.onPrimaryContainer
                    : colorScheme.onSecondaryContainer,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Maps a connection state to a human-readable label.
String _stateLabel(WsConnectionState state) {
  switch (state) {
    case WsConnectionState.idle:
      return 'idle';
    case WsConnectionState.connecting:
      return 'connecting';
    case WsConnectionState.open:
      return 'open';
    case WsConnectionState.reconnecting:
      return 'reconnecting';
    case WsConnectionState.closed:
      return 'closed';
    case WsConnectionState.failed:
      return 'failed';
  }
}

/// Maps a connection state to a chip color.
Color _stateColor(WsConnectionState state) {
  switch (state) {
    case WsConnectionState.idle:
      return Colors.blueGrey;
    case WsConnectionState.connecting:
      return Colors.lightBlue;
    case WsConnectionState.open:
      return Colors.green;
    case WsConnectionState.reconnecting:
      return Colors.orange;
    case WsConnectionState.closed:
      return Colors.blueGrey;
    case WsConnectionState.failed:
      return Colors.red;
  }
}

/// Shows a floating snackbar; errors render in the theme error color.
void _showSnackBar(BuildContext context, String message,
    {bool isError = false}) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
      behavior: SnackBarBehavior.floating,
      duration: const Duration(seconds: 4),
      backgroundColor: isError ? Theme.of(context).colorScheme.error : null,
    ),
  );
}

/// Formats a byte count for display.
String _formatBytes(int bytes) {
  if (bytes < 1024) {
    return '$bytes B';
  }
  if (bytes < 1024 * 1024) {
    return '${(bytes / 1024).toStringAsFixed(1)} KB';
  }
  return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}

/// Truncates long status text.
String _truncate(String text, int maxLength) {
  if (text.length <= maxLength) {
    return text;
  }
  return '${text.substring(0, maxLength)}…';
}
1
likes
160
points
205
downloads

Documentation

Documentation
API reference

Publisher

verified publisherbilalahmed24.com

Weekly Downloads

Deepgram SDK for Dart & Flutter - STT (pre-recorded, live, Flux), TTS (Aura, Flux), Voice Agent and Text Intelligence with resilient auto-reconnecting WebSockets.

Repository (GitHub)
View/report issues
Contributing

Topics

#deepgram #speech-to-text #text-to-speech #voice-agent #audio

License

MIT (license)

Dependencies

http, web_socket_channel

More

Packages that depend on flutter_deepgram