voicebot_flutter 0.2.0
voicebot_flutter: ^0.2.0 copied to clipboard
Drop-in voice and text store assistant for Flutter apps. The same VoiceBot assistant that runs as the web widget, native in your app over WebSocket + PCM16.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:voicebot_flutter/voicebot_flutter.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'VoiceBot SDK Example',
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
home: const SessionScreen(),
);
}
}
class SessionScreen extends StatefulWidget {
const SessionScreen({super.key});
@override
State<SessionScreen> createState() => _SessionScreenState();
}
class _SessionScreenState extends State<SessionScreen> {
final _baseUrlController =
TextEditingController(text: 'https://api.voicebot.app');
final _tokenController = TextEditingController();
final _textController = TextEditingController();
VoicebotClient? _client;
VoicebotSession? _session;
final _subs = <StreamSubscription<Object?>>[];
final _lines = <Transcript>[];
SessionStatus _status = SessionStatus.ended;
AudioState _audioState = AudioState.inactive;
String? _lastError;
bool _muted = false;
@override
void dispose() {
unawaited(_disposeSession());
_baseUrlController.dispose();
_tokenController.dispose();
_textController.dispose();
super.dispose();
}
Future<void> _disposeSession() async {
for (final s in _subs) {
await s.cancel();
}
_subs.clear();
await _session?.dispose();
await _client?.destroy();
_session = null;
_client = null;
}
void _start({required bool withVoice}) {
unawaited(_disposeSession());
setState(() {
_lines.clear();
_lastError = null;
_muted = false;
});
final client = VoicebotClient.init(
baseUrl: _baseUrlController.text.trim(),
tokenProvider: StaticTokenProvider.value(_tokenController.text.trim()),
);
final session = withVoice
? client.startVoiceSession(config: const SessionConfig(lang: 'uk'))
: client.startTextSession(lang: 'uk');
_subs.add(session.status.listen((s) => setState(() => _status = s)));
_subs.add(session.audioState.listen((a) => setState(() => _audioState = a)));
_subs.add(session.transcripts.listen((t) {
setState(() {
// Replace a trailing partial of the same role, else append.
if (_lines.isNotEmpty &&
_lines.last.role == t.role &&
_lines.last.isPartial) {
_lines[_lines.length - 1] = t;
} else {
_lines.add(t);
}
});
}));
_subs.add(session.errors.listen((e) => setState(() => _lastError = e.message)));
_subs.add(session.toolCalls.listen((c) => debugPrint('tool_call: ${c.name}')));
_subs.add(session.actions.listen((a) =>
debugPrint('action: ${a.action} handled=${a.handled} err=${a.error}')));
_subs.add(session.deepLinks.listen((d) => debugPrint('deep link: ${d.uri}')));
setState(() {
_client = client;
_session = session;
});
}
void _sendText() {
final text = _textController.text.trim();
if (text.isEmpty) return;
final ok = _session?.sendText(text) ?? false;
if (ok) _textController.clear();
}
Future<void> _toggleMute() async {
final next = !_muted;
await _session?.setMuted(next);
setState(() => _muted = next);
}
@override
Widget build(BuildContext context) {
final connected = _session != null;
return Scaffold(
appBar: AppBar(title: const Text('VoiceBot SDK Example')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _baseUrlController,
decoration: const InputDecoration(
labelText: 'baseUrl',
hintText: 'https://api.voicebot.app',
),
),
const SizedBox(height: 8),
TextField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'session token (dev JWT)',
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => _start(withVoice: true),
icon: const Icon(Icons.mic),
label: const Text('Voice'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _start(withVoice: false),
icon: const Icon(Icons.chat),
label: const Text('Text only'),
),
),
],
),
const SizedBox(height: 12),
_StatusBar(
status: _status,
audioState: _audioState,
error: _lastError,
),
const Divider(),
Expanded(
child: _lines.isEmpty
? const Center(child: Text('No transcript yet.'))
: ListView.builder(
itemCount: _lines.length,
itemBuilder: (context, i) {
final line = _lines[i];
final isUser = line.role == TranscriptRole.user;
return Align(
alignment: isUser
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isUser
? Colors.indigo.shade100
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Text(line.text + (line.isPartial ? ' …' : '')),
),
);
},
),
),
if (connected) ...[
Row(
children: [
Expanded(
child: TextField(
controller: _textController,
onSubmitted: (_) => _sendText(),
decoration: const InputDecoration(
hintText: 'Type a message…',
),
),
),
IconButton(
onPressed: _sendText,
icon: const Icon(Icons.send),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleMute,
icon: Icon(_muted ? Icons.mic_off : Icons.mic),
label: Text(_muted ? 'Unmute' : 'Mute'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
// Minimize keeps the session alive (sends no `end`).
onPressed: () => _session?.minimize(),
icon: const Icon(Icons.remove),
label: const Text('Minimize'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
// Close ends the session (sends `end`).
onPressed: () async {
await _session?.endSession();
await _disposeSession();
if (mounted) setState(() {});
},
icon: const Icon(Icons.close),
label: const Text('Close'),
),
),
],
),
],
],
),
),
);
}
}
class _StatusBar extends StatelessWidget {
const _StatusBar({
required this.status,
required this.audioState,
required this.error,
});
final SessionStatus status;
final AudioState audioState;
final String? error;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Status: ${status.name} · Audio: ${audioState.name}'),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Error: $error',
style: const TextStyle(color: Colors.red),
),
),
],
);
}
}