agenticerp_voice 0.1.2
agenticerp_voice: ^0.1.2 copied to clipboard
Voice SDK for the AgenticERP call centre: place phone calls to customers from your Flutter app through your company hotline, with the call audio in the app.
example/lib/main.dart
import 'dart:async';
import 'package:agenticerp_voice/agenticerp_voice.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Voice SDK demo',
theme: ThemeData(colorSchemeSeed: const Color(0xFF6E62FF), useMaterial3: true),
home: const CallScreen(),
);
}
class CallScreen extends StatefulWidget {
const CallScreen({super.key});
@override
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
final _baseUrl = TextEditingController(text: 'https://erp.example.com');
// In a real app the token comes from YOUR server (see README → "Token server").
final _token = TextEditingController();
final _phone = TextEditingController();
final _log = <String>[];
AerpClient? _client;
StreamSubscription<AerpClientEvent>? _clientSub;
AerpCall? _call;
AerpFromNumber? _from;
bool _muted = false;
bool _speaker = false;
bool _onHold = false;
Timer? _ticker;
bool get _inCall => _call != null && !_call!.isEnded;
void _addLog(String line) => setState(() => _log.insert(0, '${TimeOfDay.now().format(context)} $line'));
Future<void> _connect() async {
await _clientSub?.cancel();
await _client?.dispose();
final client = AerpClient(baseUrl: _baseUrl.text.trim());
_clientSub = client.events.listen((e) {
switch (e) {
case AerpClientConnected(:final user):
_addLog('Đã kết nối: ${user.displayName} (máy lẻ ${user.extension ?? '-'})');
case AerpClientDisconnected(:final reason):
_addLog('Mất kết nối: $reason');
case AerpClientError(:final code, :final message):
_addLog('Lỗi kết nối [$code] $message');
case AerpTokenWillExpire(:final expiresIn):
// Fetch a fresh token from your server, then: client.updateToken(newToken)
_addLog('Token sắp hết hạn (${expiresIn.inSeconds}s) — lấy token mới rồi gọi updateToken()');
case AerpRequestAccessToken(:final reason):
_addLog('Cần token mới ($reason) — gọi updateToken()');
}
if (mounted) setState(() {});
});
_client = client;
final result = await client.connect(_token.text);
if (!mounted) return;
setState(() {
_from = client.fromNumbers.where((n) => n.isDefault).firstOrNull ?? client.fromNumbers.firstOrNull;
});
if (!result.status) _addLog('connect thất bại: ${result.code} — ${result.message}');
}
Future<void> _makeCall() async {
final client = _client;
if (client == null || !client.isConnected) return;
// The SDK does not ask for permissions itself — the app owns that UX.
if (!await Permission.microphone.request().isGranted) {
_addLog('Chưa cấp quyền micro');
return;
}
final call = AerpCall(client);
call.events.listen((e) {
switch (e) {
case AerpSignalingStateChanged(:final state, :final reason):
_addLog('Cuộc gọi: ${_stateLabel(state)}${reason == null ? '' : ' ($reason)'}');
if (state == AerpSignalingState.answered) {
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
if (state.isTerminal) {
_ticker?.cancel();
_ticker = null;
}
case AerpMediaStateChanged(:final state):
_addLog('Âm thanh: ${state.name}');
case AerpCallInfoUpdated(:final info):
_onHold = info.onHold;
case AerpCallError(:final code, :final message):
_addLog('Lỗi cuộc gọi [$code] $message');
}
if (mounted) setState(() {});
});
setState(() {
_call = call;
_muted = false;
_onHold = false;
});
final result = await call.makeCall(AerpCallParams(
to: _phone.text,
from: _from?.number,
customData: {'booking_id': 'demo-123'},
));
if (!result.status) _addLog('makeCall thất bại: ${result.code} — ${result.message}');
}
String _stateLabel(AerpSignalingState s) => switch (s) {
AerpSignalingState.calling => 'Đang gọi…',
AerpSignalingState.ringing => 'Đang đổ chuông…',
AerpSignalingState.answered => 'Đang nói chuyện',
AerpSignalingState.busy => 'Máy bận',
AerpSignalingState.ended => 'Đã kết thúc',
};
String _talkTime() {
final d = _call?.talkDuration;
if (d == null) return '';
return ' ${d.inMinutes.toString().padLeft(2, '0')}:${(d.inSeconds % 60).toString().padLeft(2, '0')}';
}
@override
void dispose() {
_ticker?.cancel();
_call?.destroy();
_clientSub?.cancel();
_client?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final client = _client;
final call = _call;
return Scaffold(
appBar: AppBar(title: const Text('Gọi qua tổng đài')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(controller: _baseUrl, decoration: const InputDecoration(labelText: 'Địa chỉ tổng đài')),
TextField(
controller: _token,
decoration: const InputDecoration(labelText: 'Token (do máy chủ của bạn ký)'),
maxLines: 2,
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _inCall ? null : _connect,
child: Text(client?.isConnected == true ? 'Kết nối lại' : 'Kết nối'),
),
if (client?.isConnected == true) ...[
const Divider(height: 32),
InputDecorator(
decoration: const InputDecoration(labelText: 'Đầu số gọi ra'),
child: DropdownButton<AerpFromNumber>(
value: _from,
isExpanded: true,
underline: const SizedBox.shrink(),
items: [
for (final n in client!.fromNumbers)
DropdownMenuItem(value: n, child: Text('${n.number}${n.name == null ? '' : ' · ${n.name}'}')),
],
onChanged: _inCall ? null : (n) => setState(() => _from = n),
),
),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: 'Số khách hàng'),
),
const SizedBox(height: 8),
if (!_inCall)
FilledButton.icon(onPressed: _makeCall, icon: const Icon(Icons.call), label: const Text('Gọi'))
else ...[
Text(
'${_stateLabel(call!.signalingState)}${_talkTime()}',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: [
FilterChip(
label: const Text('Tắt tiếng'),
selected: _muted,
onSelected: (v) async {
await call.mute(v);
setState(() => _muted = v);
},
),
FilterChip(
label: const Text('Loa ngoài'),
selected: _speaker,
onSelected: (v) async {
await call.setSpeakerphoneOn(v);
setState(() => _speaker = v);
},
),
FilterChip(
label: const Text('Giữ máy'),
selected: _onHold,
onSelected: call.signalingState == AerpSignalingState.answered
? (v) async {
final r = await call.hold(v);
if (r.status) setState(() => _onHold = v);
}
: null,
),
],
),
const SizedBox(height: 8),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: call.hangup,
icon: const Icon(Icons.call_end),
label: const Text('Gác máy'),
),
],
],
const Divider(height: 32),
for (final line in _log) Text(line, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}