ebchat 5.1.0
ebchat: ^5.1.0 copied to clipboard
Embeddable ebchat chat SDK for Flutter apps. Drop-in chat screen and widgets on the first-party livechat protocol, with host-owned push ("notification outsourcing"), botflow forms and multi-company support.
example/lib/main.dart
import 'package:ebchat/ebchat.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'form_pickers.dart';
import 'prefs_session_store.dart';
/// Standalone host app for the ebchat mobile SDK — a manual test harness.
///
/// Two ways to point it at a gateway:
///
/// • Bake it in at build time:
/// flutter run --dart-define=EBCHAT_BASE_URL=https://api.enable.tech \
/// --dart-define=EBCHAT_API_KEY=eb_... --dart-define=EBCHAT_PARTNER=your-app
///
/// • Or just run it and paste the base URL + API key + partner id on the
/// connect screen. The values (and the guest session) are persisted, so you
/// enter them once.
///
/// Get a LIVECHAT-scoped API token + its partner id from the ebchat dashboard
/// (or locally with `make livechat-info`). Note: a real device can't resolve
/// the dev proxy's `.localhost` subdomains — use a LAN-reachable or deployed
/// gateway URL when testing off-machine.
const _defBaseUrl = String.fromEnvironment(
'EBCHAT_BASE_URL',
defaultValue: 'http://be-ebchat-gateway.localhost:3004',
);
const _defApiKey = String.fromEnvironment('EBCHAT_API_KEY');
const _defPartner = String.fromEnvironment('EBCHAT_PARTNER');
const _defLanguage = String.fromEnvironment('EBCHAT_LANGUAGE', defaultValue: 'en');
const _defFlowTrigger = String.fromEnvironment('EBCHAT_FLOW_TRIGGER');
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
runApp(EbchatExampleApp(prefs: prefs));
}
class EbchatExampleApp extends StatelessWidget {
final SharedPreferences prefs;
const EbchatExampleApp({super.key, required this.prefs});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ebchat example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF214496),
useMaterial3: true,
),
home: ConnectScreen(prefs: prefs),
);
}
}
/// Enter/confirm the connection, then open the drop-in [EbChatScreen].
class ConnectScreen extends StatefulWidget {
final SharedPreferences prefs;
const ConnectScreen({super.key, required this.prefs});
@override
State<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends State<ConnectScreen> {
late final TextEditingController _url;
late final TextEditingController _key;
late final TextEditingController _partner;
late final TextEditingController _flow;
late String _language;
bool _connecting = false;
String? _error;
SharedPreferences get _prefs => widget.prefs;
@override
void initState() {
super.initState();
// Persisted value first, else the --dart-define default.
_url = TextEditingController(text: _prefs.getString('cfg_base_url') ?? _defBaseUrl);
_key = TextEditingController(text: _prefs.getString('cfg_api_key') ?? _defApiKey);
_partner = TextEditingController(text: _prefs.getString('cfg_partner') ?? _defPartner);
_flow = TextEditingController(text: _prefs.getString('cfg_flow') ?? _defFlowTrigger);
_language = _prefs.getString('cfg_language') ?? _defLanguage;
}
@override
void dispose() {
_url.dispose();
_key.dispose();
_partner.dispose();
_flow.dispose();
super.dispose();
}
/// Namespace the persisted session by account, so switching the app between
/// keys never resumes the wrong guest.
String _scope(String partner, String apiKey) {
final tail = apiKey.length > 6 ? apiKey.substring(apiKey.length - 6) : apiKey;
return '${partner}_$tail'.replaceAll(RegExp(r'[^A-Za-z0-9_]'), '');
}
Future<void> _connect() async {
final baseUrl = _url.text.trim();
final apiKey = _key.text.trim();
final partner = _partner.text.trim();
final flow = _flow.text.trim();
final uri = Uri.tryParse(baseUrl);
if (uri == null || !uri.hasScheme || !uri.hasAuthority) {
setState(() => _error = 'Enter a full base URL, e.g. https://api.enable.tech');
return;
}
if (apiKey.isEmpty || partner.isEmpty) {
setState(() => _error = 'API key and partner id are both required.');
return;
}
// Remember the config for next launch.
await _prefs.setString('cfg_base_url', baseUrl);
await _prefs.setString('cfg_api_key', apiKey);
await _prefs.setString('cfg_partner', partner);
await _prefs.setString('cfg_flow', flow);
await _prefs.setString('cfg_language', _language);
setState(() {
_connecting = true;
_error = null;
});
final store = PrefsSessionStore(_prefs, scope: _scope(partner, apiKey));
try {
final chat = await EbChat.init(
baseUrl: uri,
apiKey: apiKey,
partnerId: partner,
user: const EbChatUser.guest(),
language: _language,
sessionStore: store,
);
if (!mounted) return;
await Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatScreen(
chat: chat,
store: store,
flowTrigger: flow.isEmpty ? null : flow,
),
));
} catch (e) {
if (mounted) setState(() => _error = '$e');
} finally {
if (mounted) setState(() => _connecting = false);
}
}
Future<void> _resetSavedSession() async {
await PrefsSessionStore(_prefs, scope: _scope(_partner.text.trim(), _key.text.trim()))
.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Saved guest session cleared — next connect starts fresh.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ebchat SDK — test harness')),
body: AbsorbPointer(
absorbing: _connecting,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Point this at a gateway with a LIVECHAT-scoped API token + its '
'partner id, then open the embedded chat.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
_field(_url, 'Base URL',
hint: 'https://api.enable.tech', keyboard: TextInputType.url),
const SizedBox(height: 12),
_field(_key, 'API key (eb_…)', hint: 'eb_live_…'),
const SizedBox(height: 12),
_field(_partner, 'Partner id', hint: 'your-app'),
const SizedBox(height: 12),
_field(_flow, 'Flow trigger (optional)',
hint: 'deep-link a specific botflow'),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text('Language', style: Theme.of(context).textTheme.labelLarge),
),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'en', label: Text('English')),
ButtonSegment(value: 'ar', label: Text('العربية')),
],
selected: {_language},
onSelectionChanged: (s) => setState(() => _language = s.first),
),
if (_error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_error!,
style:
TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _connecting ? null : _connect,
icon: _connecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.chat_bubble_outline),
label: Text(_connecting ? 'Connecting…' : 'Open chat'),
),
const SizedBox(height: 8),
TextButton(
onPressed: _connecting ? null : _resetSavedSession,
child: const Text('Reset saved session'),
),
],
),
),
);
}
Widget _field(TextEditingController c, String label,
{String? hint, TextInputType? keyboard}) {
return TextField(
controller: c,
keyboardType: keyboard,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
),
);
}
}
/// Hosts the drop-in [EbChatScreen] under the app's own AppBar.
class ChatScreen extends StatelessWidget {
final EbChat chat;
final SessionStore store;
final String? flowTrigger;
const ChatScreen({
super.key,
required this.chat,
required this.store,
this.flowTrigger,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Support'),
actions: [
IconButton(
tooltip: 'Reset guest session',
icon: const Icon(Icons.refresh),
onPressed: () async {
await store.clear();
if (context.mounted) Navigator.of(context).pop();
},
),
],
),
// Botflow form questions are answered by the SDK; it only needs the two
// platform hooks (see form_pickers.dart). Voice notes stay omitted, so
// the composer shows text + send — wire onVoiceStart/Stop/Cancel to add
// the mic.
body: EbChatScreen(
chat: chat,
flowTrigger: flowTrigger,
onPickFile: (request) => pickFormFile(context, request),
onResolveLocation: resolveFormLocation,
),
);
}
}