sautikit 0.1.0
sautikit: ^0.1.0 copied to clipboard
Place and receive real phone calls from a Flutter app over SautiKit's voice network. WebRTC audio on Android, iOS, web, macOS and Windows.
example/lib/main.dart
// The smallest thing that rings a phone.
//
// Deliberately one file and one screen: an example whose own structure has to
// be understood first is an example that teaches its structure rather than the
// package. Paste your token and endpoint, press Call.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:sautikit/sautikit.dart';
void main() => runApp(const ExampleApp());
/// Demonstrates placing one call.
class ExampleApp extends StatelessWidget {
/// Creates the app.
const ExampleApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(
title: 'SautiKit example',
home: DialScreen(),
);
}
/// A number field, a button, and whatever the network last said.
class DialScreen extends StatefulWidget {
/// Creates the screen.
const DialScreen({super.key});
@override
State<DialScreen> createState() => _DialScreenState();
}
class _DialScreenState extends State<DialScreen> {
// Your server mints these — see the README. Taken at RUNTIME rather than
// compiled in, because that is what a real app does: it fetches a token
// when the person signs in, and the token is short-lived enough that baking
// one into a build is useless by the time the build finishes.
final TextEditingController _token = TextEditingController(
text: const String.fromEnvironment('SAUTIKIT_TOKEN'),
);
final TextEditingController _endpoint = TextEditingController(
text: const String.fromEnvironment(
'SAUTIKIT_ENDPOINT',
defaultValue: 'wss://webrtc.helloduty.com',
),
);
final TextEditingController _number =
TextEditingController(text: '+254700000001');
SautikitPhone? _phone;
String _status = 'Not connected';
bool _ready = false;
bool _onCall = false;
/// TURN servers, from the same token mint. Pasted as JSON because that is
/// how the mint returns them, and a call without them works on the
/// gateway's own network and nowhere else.
final TextEditingController _iceJson = TextEditingController();
List<Map<String, dynamic>>? _ice;
/// The protocol trace, newest first. An example that shows what the SDK is
/// doing teaches more than one that only shows whether it worked.
final List<String> _trace = <String>[];
@override
void initState() {
super.initState();
}
Future<void> _start() async {
if (_token.text.trim().isEmpty) {
setState(() => _status = 'Paste a token to connect');
return;
}
await _phone?.dispose();
setState(() {
_ready = false;
_status = 'Connecting…';
});
// withToken, not the main constructor: this is a paste-a-token spike,
// and it will stop working when that token expires. A real app passes a
// `credentials` callback that re-mints — see the README.
final SautikitPhone phone = SautikitPhone.withToken(
token: _token.text.trim(),
endpoint: _endpoint.text.trim(),
iceServers: _ice,
onLog: (String m) => setState(() {
_trace.insert(0, m);
if (_trace.length > 40) _trace.removeLast();
}),
);
phone.events.listen((SautikitEvent e) {
if (!mounted) return;
setState(() {
switch (e) {
case Registered():
_ready = true;
_status = 'Ready';
case Ringing():
_status = 'Ringing…';
case Answered():
_onCall = true;
_status = 'Connected';
case Declined(:final String? reason):
_onCall = false;
_status = 'Declined${reason == null ? '' : ' — $reason'}';
case Hangup(:final String? reason):
_onCall = false;
_status = 'Ended${reason == null ? '' : ' — $reason'}';
case ErrorEvent(:final Object error):
_status = '$error';
default:
break;
}
});
});
await phone.connect();
if (mounted) setState(() => _phone = phone);
}
@override
void dispose() {
// Releases the microphone. Skipping this leaves the recording indicator
// showing on a phone long after the person has moved on.
_phone?.dispose();
_number.dispose();
_token.dispose();
_endpoint.dispose();
_iceJson.dispose();
super.dispose();
}
void _readIce() {
final String raw = _iceJson.text.trim();
if (raw.isEmpty) {
_ice = null;
return;
}
try {
final Object? d = jsonDecode(raw);
final Object? list = d is Map ? d['iceServers'] : d;
if (list is List) {
_ice = list
.whereType<Map<dynamic, dynamic>>()
.map((Map<dynamic, dynamic> m) => m.cast<String, dynamic>())
.toList();
}
} catch (_) {
_ice = null;
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('SautiKit')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextField(
controller: _token,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Token (from your server)',
hintText: 'eyJhbGciOi…',
),
),
const SizedBox(height: 10),
TextField(
controller: _endpoint,
decoration: const InputDecoration(labelText: 'Gateway'),
),
const SizedBox(height: 10),
TextField(
controller: _iceJson,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'TURN servers (the mint\'s turnServer JSON)',
hintText: '{"iceServers":[…]}',
),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () {
_readIce();
_start();
},
child: const Text('Connect'),
),
const Divider(height: 32),
TextField(
controller: _number,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Number to ring',
hintText: '+254…',
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: !_ready || _phone == null
? null
: () async {
if (_onCall) {
_phone!.hangup();
return;
}
// Awaited AND caught. call() throws a SautikitError
// for anything that stops the invitation leaving —
// a denied microphone most often — and an example
// that drops it on the floor teaches the wrong habit
// and debugs nothing.
try {
await _phone!.call(_number.text.trim());
} on SautikitError catch (e) {
setState(() => _status = '${e.code}: ${e.message}');
}
},
child: Text(_onCall ? 'Hang up' : 'Call'),
),
const SizedBox(height: 14),
Text(_status, textAlign: TextAlign.center),
const SizedBox(height: 14),
// The trace, so a failure says what happened rather than only
// that it happened.
for (final String line in _trace)
Text(line, style: const TextStyle(fontSize: 11)),
],
),
),
);
}