frontface_chat 1.7.2
frontface_chat: ^1.7.2 copied to clipboard
Native Flutter SDK for FrontFace AI chat with lead capture and live human agent handoff support.
import 'package:flutter/material.dart';
import 'package:frontface_chat/frontface_chat.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const ExampleApp());
}
enum ExampleLanguage { english, arabic }
FrontFaceChatStrings get _englishStrings => FrontFaceChatStrings.english.copyWith(
field2Label: 'Phone Number',
);
FrontFaceChatStrings get _arabicStrings => FrontFaceChatStrings.arabic;
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'FrontFace Chat Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.black),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _projectIdController = TextEditingController();
final _publishableKeyController = TextEditingController();
final _mapsKeyController = TextEditingController();
final _identityJwtController = TextEditingController();
final _formKey = GlobalKey<FormState>();
String? _debugStatus;
ExampleLanguage _language = ExampleLanguage.english;
FrontFaceChatStrings get _strings => switch (_language) {
ExampleLanguage.english => _englishStrings,
ExampleLanguage.arabic => _arabicStrings,
};
bool get _isArabic => _language == ExampleLanguage.arabic;
@override
void dispose() {
_projectIdController.dispose();
_publishableKeyController.dispose();
_mapsKeyController.dispose();
_identityJwtController.dispose();
super.dispose();
}
FrontFaceChatConfig? _buildConfig() {
if (!_formKey.currentState!.validate()) return null;
final mapsKey = _mapsKeyController.text.trim();
final hasMapsKey =
mapsKey.isNotEmpty && mapsKey != 'YOUR_GOOGLE_MAPS_API_KEY';
return FrontFaceChatConfig(
projectId: _projectIdController.text.trim(),
publishableKey: _publishableKeyController.text.trim(),
debugLogging: true,
enableCalls: true,
// Attachments are off by default in the SDK — enable them here for demos.
// Maps key is optional: with a key → map picker; without → GPS share only.
attachments: FrontFaceAttachmentsConfig(
enableLocation: true,
enableImages: true,
enableAudio: true,
googleMapsApiKey: hasMapsKey ? mapsKey : null,
),
);
}
Future<void> _openChat({FrontFaceChatTheme? theme}) async {
final config = _buildConfig();
if (config == null || !mounted) return;
final identityJwt = _identityJwtController.text.trim();
// Custom route so the example can switch language while chat is open
// via FrontFaceChatProvider.updateStrings().
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (routeContext) => ChangeNotifierProvider(
create: (_) => FrontFaceChat.createProvider(
config: config,
strings: _strings,
),
child: _ExampleChatHost(
theme: theme ?? const FrontFaceChatTheme(),
initialLanguage: _language,
identityJwt: identityJwt.isEmpty ? null : identityJwt,
),
),
),
);
}
Future<void> _openCallUiPreview({
CallPreviewPhase phase = CallPreviewPhase.ringing,
FrontFaceChatTheme? theme,
}) async {
final session = CallSession.preview(
phase: phase,
agentName: _isArabic ? 'وكيل الدعم' : 'Support Agent',
);
if (phase == CallPreviewPhase.ended) {
try {
await session.done.timeout(const Duration(seconds: 2));
} catch (_) {}
}
if (!mounted) {
await session.dispose();
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => FrontFaceCallScreen(
session: session,
strings: _strings,
theme: theme ?? const FrontFaceChatTheme(),
),
),
);
}
Future<void> _pickCallUiPreview() async {
final phase = await showModalBottomSheet<CallPreviewPhase>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.ring_volume_outlined),
title: Text(
_isArabic ? 'رنين → متصل' : 'Ringing → connected',
),
subtitle: Text(
_isArabic
? 'معاينة التدفق الكامل بدون مكالمة حقيقية'
: 'Full flow preview without a real call',
),
onTap: () => Navigator.pop(context, CallPreviewPhase.ringing),
),
ListTile(
leading: const Icon(Icons.support_agent_outlined),
title: Text(_isArabic ? 'متصل' : 'Connected'),
onTap: () =>
Navigator.pop(context, CallPreviewPhase.connected),
),
ListTile(
leading: const Icon(Icons.call_end_outlined),
title: Text(_isArabic ? 'انتهت المكالمة' : 'Call ended'),
onTap: () => Navigator.pop(context, CallPreviewPhase.ended),
),
const SizedBox(height: 8),
],
),
);
},
);
if (phase == null || !mounted) return;
await _openCallUiPreview(phase: phase);
}
Future<void> _corruptSessionToken() async {
if (!_formKey.currentState!.validate()) return;
final projectId = _projectIdController.text.trim();
final corrupted =
await FrontFaceChat.debugCorruptSessionToken(projectId);
if (!mounted) return;
setState(() {
_debugStatus = corrupted
? (_isArabic
? 'تم إفساد رمز الجلسة. افتح المحادثة — يجب أن تظهر '
'نموذج البيانات أولاً بدون تحية، وبعد الإرسال تُنشأ '
'جلسة جديدة مع تحية من الـ API.'
: 'Session token corrupted. Open chat — you should see the lead '
'form (chat cleared, no greeting yet). After submit, a new '
'session is created and the API greeting appears.')
: (_isArabic
? 'لا يوجد رمز جلسة بعد. افتح المحادثة وأكمل النموذج أو '
'أرسل رسالة أولاً، ثم أغلق المحادثة وحاول مرة أخرى.'
: 'No stored session token yet. Open chat, complete the lead '
'form / send a message first, close chat, then try again.');
});
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: _isArabic ? TextDirection.rtl : TextDirection.ltr,
child: Scaffold(
appBar: AppBar(
title: Text(_isArabic ? 'مثال FrontFace Chat' : 'FrontFace Chat Example'),
),
body: SafeArea(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
_isArabic
? 'أدخل بيانات Mobile SDK من لوحة FrontFace، ثم افتح المحادثة.'
: 'Enter your Mobile SDK credentials from the FrontFace dashboard, then open chat.',
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
const SizedBox(height: 20),
Text(
_isArabic ? 'اللغة' : 'Language',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SegmentedButton<ExampleLanguage>(
segments: const [
ButtonSegment(
value: ExampleLanguage.english,
label: Text('English'),
icon: Icon(Icons.language, size: 18),
),
ButtonSegment(
value: ExampleLanguage.arabic,
label: Text('العربية'),
icon: Icon(Icons.translate, size: 18),
),
],
selected: {_language},
onSelectionChanged: (value) {
setState(() => _language = value.first);
},
),
const SizedBox(height: 8),
Text(
_isArabic
? 'جرّب رقم الهاتف، زر الرجوع، واتجاه الواجهة بالعربية.'
: 'Test phone label, back button, and layout direction.',
style: const TextStyle(fontSize: 13, color: Colors.black54),
),
const SizedBox(height: 24),
TextFormField(
controller: _projectIdController,
decoration: InputDecoration(
labelText: _isArabic ? 'معرّف المشروع' : 'Project ID',
hintText: 'e.g. 00000000-0000-4000-8000-000000000000',
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return _isArabic
? 'معرّف المشروع مطلوب'
: 'Project ID is required';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _publishableKeyController,
decoration: InputDecoration(
labelText:
_isArabic ? 'المفتاح العام' : 'Publishable key',
hintText: 'pk_...',
border: const OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: (value) {
final trimmed = value?.trim() ?? '';
if (trimmed.isEmpty) {
return _isArabic
? 'المفتاح العام مطلوب'
: 'Publishable key is required';
}
if (!trimmed.startsWith('pk_')) {
return _isArabic
? 'يجب أن يبدأ المفتاح بـ pk_'
: 'Key should start with pk_';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _mapsKeyController,
decoration: InputDecoration(
labelText: _isArabic
? 'مفتاح Google Maps (اختياري)'
: 'Google Maps API key (optional)',
hintText: 'AIza… (optional — map + place search)',
border: const OutlineInputBorder(),
helperText: _isArabic
? 'بدون مفتاح: مشاركة GPS فقط. مع مفتاح: خريطة وبحث — '
'يجب وضع نفس المفتاح أيضاً في AppDelegate و AndroidManifest ثم إعادة البناء.'
: 'No key → GPS share only. With a key → map + search — '
'also set the same key in AppDelegate & AndroidManifest, then rebuild.',
helperMaxLines: 4,
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextFormField(
controller: _identityJwtController,
decoration: InputDecoration(
labelText: _isArabic
? 'رمز الهوية JWT (مطلوب لزر الاتصال)'
: 'Identity JWT (required for Call button)',
hintText: 'eyJ…',
border: const OutlineInputBorder(),
helperText: _isArabic
? 'بدون JWT يبقى زر الاتصال مخفياً (not_verified). '
'الصقه من باكندك بعد تسجيل الدخول، ثم افتح المحادثة وأكمل النموذج.'
: 'Without a JWT the Call button stays hidden (not_verified). '
'Paste the token your backend mints after login, then open '
'chat and finish the lead form.',
helperMaxLines: 4,
),
textInputAction: TextInputAction.done,
maxLines: 3,
onFieldSubmitted: (_) => _openChat(),
),
const SizedBox(height: 28),
ElevatedButton(
onPressed: () => _openChat(),
child: Text(_isArabic ? 'فتح المحادثة' : 'Open chat'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => _openChat(
theme: const FrontFaceChatTheme(
primaryColor: Color(0xFF2563EB),
userBubbleColor: Color(0xFF2563EB),
userBubbleTextColor: Colors.white,
assistantBubbleColor: Color(0xFFEFF6FF),
assistantBubbleTextColor: Color(0xFF1E3A8A),
assistantBubbleBorderColor: Color(0xFFBFDBFE),
),
),
child: Text(
_isArabic
? 'فتح المحادثة (سمة مخصصة)'
: 'Open chat (custom theme)',
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _pickCallUiPreview,
icon: const Icon(Icons.call_outlined),
label: Text(
_isArabic
? 'معاينة واجهة المكالمة فقط'
: 'Preview call UI only',
),
),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 8),
Text(
_isArabic ? 'اختبار انتهاء الجلسة' : 'Session expiry testing',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_isArabic
? 'رموز الجلسة تنتهي بعد 24 ساعة من عدم النشاط. '
'الرمز التالف والمنتهي يعطيان نفس الخطأ 403:\n'
'1. افتح المحادثة → أكمل النموذج → أرسل رسالة\n'
'2. أغلق المحادثة → اضغط «إفساد رمز الجلسة»\n'
'3. افتح المحادثة مرة أخرى\n'
'المتوقع: مسح المحادثة وإظهار النموذج ثم تحية الـ API.'
: 'Session tokens expire after 24h of inactivity. Expired and '
'tampered tokens both return 403 SESSION_INVALID — so you can '
'test without waiting:\n'
'1. Open chat → complete lead form → send a message\n'
'2. Close chat → tap “Corrupt stored session token”\n'
'3. Open chat again (or send a message)\n'
'Expected: chat cleared, lead form shown again; after submit, '
'a new session is created and the API greeting appears.',
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
height: 1.4,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _corruptSessionToken,
icon: const Icon(Icons.bug_report_outlined),
label: Text(
_isArabic
? 'إفساد رمز الجلسة المخزّن'
: 'Corrupt stored session token',
),
),
if (_debugStatus != null) ...[
const SizedBox(height: 12),
Text(
_debugStatus!,
style: const TextStyle(fontSize: 13, height: 1.4),
),
],
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _openChat(),
child: const Icon(Icons.chat_bubble_outline),
),
),
);
}
}
/// Hosts [FrontFaceChatScreen] and a language toggle that calls
/// [FrontFaceChatProvider.updateStrings] live while chat is open.
class _ExampleChatHost extends StatefulWidget {
final FrontFaceChatTheme theme;
final ExampleLanguage initialLanguage;
final String? identityJwt;
const _ExampleChatHost({
required this.theme,
required this.initialLanguage,
this.identityJwt,
});
@override
State<_ExampleChatHost> createState() => _ExampleChatHostState();
}
class _ExampleChatHostState extends State<_ExampleChatHost> {
late ExampleLanguage _language = widget.initialLanguage;
var _identifyStarted = false;
String? _identifyStatus;
FrontFaceChatStrings get _strings => switch (_language) {
ExampleLanguage.english => _englishStrings,
ExampleLanguage.arabic => _arabicStrings,
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _tryIdentify());
}
Future<void> _tryIdentify() async {
final jwt = widget.identityJwt?.trim();
if (jwt == null || jwt.isEmpty || _identifyStarted || !mounted) return;
_identifyStarted = true;
final provider = context.read<FrontFaceChatProvider>();
// Wait until chat init finishes (visitor/session), then identify.
for (var i = 0; i < 40 && mounted && provider.isInitializing; i++) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
if (!mounted) return;
try {
await FrontFaceChat.identify(provider: provider, identityToken: jwt);
if (!mounted) return;
setState(() => _identifyStatus = 'identify: ok');
await provider.refreshCallAvailability();
if (!mounted) return;
setState(() {});
} catch (e) {
if (!mounted) return;
setState(() => _identifyStatus = 'identify failed: $e');
}
}
void _setLanguage(ExampleLanguage language) {
if (language == _language) return;
setState(() => _language = language);
context.read<FrontFaceChatProvider>().updateStrings(_strings);
}
String _callDebugLabel(FrontFaceChatProvider provider) {
if (provider.showCallButton) return 'Call button: visible';
final reason = provider.callAvailability?.reason;
if (widget.identityJwt == null || widget.identityJwt!.trim().isEmpty) {
return 'Call button hidden: paste Identity JWT on home (not_verified)';
}
if (reason != null) return 'Call button hidden: ${reason.name}';
if (provider.isInitializing) return 'Call button: waiting for session…';
return 'Call button hidden: finish lead form / wait for session';
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
FrontFaceChatScreen(theme: widget.theme),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 56),
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(24),
color: Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: SegmentedButton<ExampleLanguage>(
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
segments: const [
ButtonSegment(
value: ExampleLanguage.english,
label: Text('EN'),
),
ButtonSegment(
value: ExampleLanguage.arabic,
label: Text('AR'),
),
],
selected: {_language},
onSelectionChanged: (value) => _setLanguage(value.first),
),
),
),
),
),
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Consumer<FrontFaceChatProvider>(
builder: (context, provider, _) {
final lines = <String>[
_callDebugLabel(provider),
if (_identifyStatus != null) _identifyStatus!,
];
return Material(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
lines.join('\n'),
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.3,
),
),
),
);
},
),
),
),
),
],
);
}
}