sautikit 0.1.0 copy "sautikit: ^0.1.0" to clipboard
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)),
            ],
          ),
        ),
      );
}
0
likes
160
points
39
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Place and receive real phone calls from a Flutter app over SautiKit's voice network. WebRTC audio on Android, iOS, web, macOS and Windows.

Homepage
Repository (GitHub)

Topics

#voip #webrtc #telephony #calling

License

MIT (license)

Dependencies

flutter, flutter_webrtc, web, web_socket_channel

More

Packages that depend on sautikit