frontface_chat 1.7.2 copy "frontface_chat: ^1.7.2" to clipboard
frontface_chat: ^1.7.2 copied to clipboard

PlatformAndroid

Native Flutter SDK for FrontFace AI chat with lead capture and live human agent handoff support.

example/lib/main.dart

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,
                        ),
                      ),
                    ),
                  );
                },
              ),
            ),
          ),
        ),
      ],
    );
  }
}