find_ai_chat 0.7.0 copy "find_ai_chat: ^0.7.0" to clipboard
find_ai_chat: ^0.7.0 copied to clipboard

Platformweb

Flutter SDK for the Find AI chat assistant. Drop-in widget with floating, drawer and embedded modes, SSE streaming, and signed-mode visitor auth.

example/lib/main.dart

import 'package:find_ai_chat/find_ai_chat.dart';
import 'package:flutter/material.dart';

import 'demo_backend.dart';
import 'scripted_recognizer.dart';

/// Backend real, opcional. Sin estas dos defines la app corre contra
/// [DemoChatBackend], un servidor ficticio en memoria: no hace falta tocar
/// código ni tener una conexión configurada para probar la interfaz.
///
/// ```bash
/// flutter run -d chrome \
///   --dart-define=FIND_CHAT_CONNECTION_ID=... \
///   --dart-define=FIND_CHAT_BASE_URL=https://api.tuapp.com
/// ```
const String kRealConnectionId = String.fromEnvironment('FIND_CHAT_CONNECTION_ID');
const String kRealBaseUrl = String.fromEnvironment('FIND_CHAT_BASE_URL');
const bool kHasRealBackend = kRealConnectionId != '' && kRealBaseUrl != '';

enum _Backend { demo, real }

/// De dónde sale el dictado en el playground.
enum _Voice {
  /// Sin micrófono: el compositor queda como estaba.
  off,

  /// Reconocimiento del navegador (pide permiso; no anda en todos).
  browser,

  /// Motor ficticio: sirve para ver la interfaz sin micrófono.
  scripted,
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await seedDemoConversation();
  runApp(const ExampleApp());
}

/// Deja la conversación de demo apuntando al historial precargado si no hay
/// una en curso, así el chat abre con mensajes desde la primera vez (y se ve
/// que arranca en el último). "Eliminar conversación" la vacía; al recargar
/// vuelve a sembrarse.
Future<void> seedDemoConversation() async {
  final store = FindChatSessionStore(DemoChatBackend.connectionId);
  if (await store.getConversationId() == null) {
    await store.setConversationId(DemoChatBackend.seededConversationId);
  }
}

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'find_ai_chat example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF635BFF),
        useMaterial3: true,
        fontFamily: 'Inter',
        scaffoldBackgroundColor: const Color(0xFFF6F8FA),
      ),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  FindChatMode _mode = FindChatMode.floating;
  FindChatTheme? _theme;
  FindChatVisualStyle? _visualStyle;
  bool _hideNewConversation = false;

  /// `visitor_can_delete` que sirve el backend de demo. Decide si el menú "+"
  /// ofrece "Eliminar conversación"; en false, además, los DELETE dan 403.
  bool _visitorCanDelete = true;
  _Voice _voice = _Voice.scripted;
  FindChatTranscriptMode _transcriptMode = FindChatTranscriptMode.editInComposer;
  bool _showEmptyState = true;
  _Backend _backend = kHasRealBackend ? _Backend.real : _Backend.demo;

  /// Últimos eventos que emitió el widget, para ver el canal de analítica.
  final List<String> _events = <String>[];

  /// Se crea una sola vez: el SDK compara los objetos inyectados por
  /// identidad, así que armarlo dentro de `build` lo recrearía en cada frame.
  late final ScriptedSpeechRecognizer _scriptedRecognizer = ScriptedSpeechRecognizer();

  late final FindChatCallbacks _callbacks = FindChatCallbacks(
    onEvent: (event) {
      if (!mounted) return;
      setState(() {
        _events.insert(0, event.type);
        if (_events.length > 8) _events.removeLast();
      });
    },
  );

  FindChatAudioConfig? get _audioConfig {
    switch (_voice) {
      case _Voice.off:
        return null;
      case _Voice.browser:
        return FindChatAudioConfig(transcriptMode: _transcriptMode);
      case _Voice.scripted:
        return FindChatAudioConfig(
          transcriptMode: _transcriptMode,
          recognizer: _scriptedRecognizer,
        );
    }
  }

  static const _demoEmptyState = FindChatEmptyState(
    title: '¿En qué te ayudo?',
    description: 'Preguntá lo que quieras, o empezá por uno de estos atajos.',
    placeholder: 'Escribí o dictá tu consulta…',
    suggestions: [
      FindChatSuggestion(
        id: 'pedido',
        label: 'Ver mi pedido',
        message: 'Quiero saber el estado de mi último pedido.',
        icon: Icons.local_shipping_outlined,
      ),
      FindChatSuggestion(
        id: 'markdown',
        label: 'Ver formato',
        message: 'markdown',
        icon: Icons.text_snippet_outlined,
      ),
      FindChatSuggestion(
        id: 'humano',
        label: 'Hablar con alguien',
        message: 'Necesito hablar con una persona del equipo.',
        icon: Icons.support_agent_outlined,
      ),
    ],
  );

  late FindChatController _controller = _createController(_backend);

  /// Cambia con cada controller nuevo: fuerza a remontar `FindChatWidget`,
  /// que de otro modo seguiría escuchando al controller anterior.
  int _generation = 0;

  String get _connectionId =>
      _backend == _Backend.demo ? DemoChatBackend.connectionId : kRealConnectionId;

  FindChatController _createController(_Backend backend) {
    switch (backend) {
      case _Backend.demo:
        return FindChatController(
          connectionId: DemoChatBackend.connectionId,
          client: FindChatClient(
            baseUrl: DemoChatBackend.baseUrl,
            connectionId: DemoChatBackend.connectionId,
            httpClient: DemoChatBackend(visitorCanDelete: _visitorCanDelete),
          ),
        );
      case _Backend.real:
        return FindChatController(connectionId: kRealConnectionId, baseUrl: kRealBaseUrl);
    }
  }

  /// Reemplaza el controller (y el backend ficticio, que vive en él). El
  /// anterior se dispone recién después del frame, cuando el widget viejo ya
  /// se desmontó y dejó de escucharlo.
  void _replaceController(_Backend backend) {
    final previous = _controller;
    setState(() {
      _backend = backend;
      _controller = _createController(backend);
      _generation++;
    });
    WidgetsBinding.instance.addPostFrameCallback((_) => previous.dispose());
  }

  Future<void> _selectBackend(_Backend backend) async {
    if (backend == _backend) return;
    if (backend == _Backend.real && !kHasRealBackend) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
        content: Text(
          'Para usar un backend real corré la app con '
          '--dart-define=FIND_CHAT_CONNECTION_ID=... y --dart-define=FIND_CHAT_BASE_URL=...',
        ),
      ));
      return;
    }
    _replaceController(backend);
  }

  /// Vuelve a sembrar el historial de demo y arranca un backend limpio: útil
  /// para repetir la prueba de "abre en el último mensaje".
  Future<void> _resetDemo() async {
    await FindChatSessionStore(DemoChatBackend.connectionId)
        .setConversationId(DemoChatBackend.seededConversationId);
    if (!mounted) return;
    _replaceController(_Backend.demo);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Widget _buildChat(FindChatMode mode) {
    return FindChatWidget(
      key: ValueKey('chat-$_generation'),
      connectionId: _connectionId,
      controller: _controller,
      mode: mode,
      theme: _theme,
      visualStyle: _visualStyle,
      hiddenActions: _hideNewConversation ? const {FindChatActionIds.newConversation} : const {},
      audio: _audioConfig,
      emptyState: _showEmptyState ? _demoEmptyState : null,
      callbacks: _callbacks,
    );
  }

  /// Ancho a partir del cual el chat embebido va al costado del playground.
  /// Por debajo se muestra como una sección más, así los controles (y la
  /// forma de volver a otro modo) siguen siempre a mano.
  static const double _kSideBySideWidth = 900;

  @override
  Widget build(BuildContext context) {
    final embedded = _mode == FindChatMode.embedded;

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            _buildHeader(),
            Expanded(
              // Decide por el ancho que realmente tiene el cuerpo, no por el
              // de la ventana: es lo que importa para que entre al costado.
              child: LayoutBuilder(
                builder: (context, constraints) {
                  final sideBySide = embedded && constraints.maxWidth >= _kSideBySideWidth;
                  if (!sideBySide) return _buildPlayground(inlineChat: embedded);
                  return Row(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      Expanded(child: _buildPlayground()),
                      SizedBox(width: 460, child: _buildEmbeddedChat()),
                    ],
                  );
                },
              ),
            ),
          ],
        ),
      ),
      floatingActionButton: embedded ? null : _buildChat(_mode),
    );
  }

  Widget _buildHeader() {
    return Container(
      padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
      decoration: const BoxDecoration(
        color: Colors.white,
        border: Border(bottom: BorderSide(color: Color(0xFFE3E8EF))),
      ),
      child: Row(
        children: [
          Container(
            width: 32,
            height: 32,
            decoration: BoxDecoration(
              gradient: const LinearGradient(
                colors: [Color(0xFF635BFF), Color(0xFF8B5CF6)],
              ),
              borderRadius: BorderRadius.circular(8),
            ),
            padding: const EdgeInsets.all(4),
            child: Image.asset('assets/find_logo.png', fit: BoxFit.contain),
          ),
          const SizedBox(width: 12),
          const Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'find_ai_chat',
                style: TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w600,
                  color: Color(0xFF0A2540),
                  letterSpacing: -0.3,
                ),
              ),
              Text(
                'Interactive SDK playground',
                style: TextStyle(
                  fontSize: 12,
                  color: Color(0xFF596780),
                ),
              ),
            ],
          ),
          const Spacer(),
          _buildPill(_backend == _Backend.demo ? 'Demo backend' : 'Real backend'),
        ],
      ),
    );
  }

  Widget _buildPill(String label) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
      decoration: BoxDecoration(
        color: const Color(0xFFF0EEFF),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Text(
        label,
        style: const TextStyle(
          fontSize: 11,
          fontWeight: FontWeight.w500,
          color: Color(0xFF635BFF),
        ),
      ),
    );
  }

  Widget _buildEventsCard() {
    if (_events.isEmpty) {
      return const Text(
        'Todavía no pasó nada. Mandá un mensaje, tocá un atajo o grabá algo.',
        style: TextStyle(fontSize: 12, color: Color(0xFF596780)),
      );
    }
    return Wrap(
      spacing: 6,
      runSpacing: 6,
      children: [for (final event in _events) _buildPill(event)],
    );
  }

  /// [inlineChat] agrega el chat embebido como sección, debajo de los modos
  /// (pantallas angostas: no entra al costado).
  Widget _buildPlayground({bool inlineChat = false}) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(24),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildSection(
            title: 'Backend',
            subtitle: kHasRealBackend
                ? 'Real: $kRealBaseUrl'
                : 'Sin dart-defines: solo el backend ficticio en memoria',
            child: _buildChipGroup<_Backend>(
              options: {
                _Backend.demo: ('Demo (sin servidor)', Icons.offline_bolt_outlined),
                _Backend.real: ('Real (dart-define)', Icons.cloud_outlined),
              },
              selected: _backend,
              onSelected: _selectBackend,
            ),
          ),
          const SizedBox(height: 20),
          _buildSection(
            title: 'Display Mode',
            child: _buildChipGroup<FindChatMode>(
              options: {
                FindChatMode.floating: ('Floating', Icons.bubble_chart_outlined),
                FindChatMode.drawer: ('Drawer', Icons.menu_open_rounded),
                FindChatMode.embedded: ('Embedded', Icons.dashboard_outlined),
              },
              selected: _mode,
              onSelected: (v) => setState(() => _mode = v),
            ),
          ),
          if (inlineChat) ...[
            const SizedBox(height: 20),
            _buildSection(
              title: 'Embedded chat',
              subtitle: 'Un widget más del árbol: ocupa el espacio que le da el padre',
              child: SizedBox(
                height: 560,
                child: _buildEmbeddedChat(padding: EdgeInsets.zero),
              ),
            ),
          ],
          const SizedBox(height: 20),
          _buildSection(
            title: 'Theme',
            child: _buildChipGroup<FindChatTheme?>(
              options: {
                null: ('From config', Icons.settings_outlined),
                FindChatTheme.light: ('Light', Icons.light_mode_outlined),
                FindChatTheme.dark: ('Dark', Icons.dark_mode_outlined),
              },
              selected: _theme,
              onSelected: (v) => setState(() => _theme = v),
            ),
          ),
          const SizedBox(height: 20),
          _buildSection(
            title: 'Visual Style',
            child: _buildChipGroup<FindChatVisualStyle?>(
              options: {
                null: ('From config', Icons.settings_outlined),
                FindChatVisualStyle.classic: ('Classic', Icons.chat_outlined),
                FindChatVisualStyle.gptStyle: ('GPT style', Icons.auto_awesome_outlined),
              },
              selected: _visualStyle,
              onSelected: (v) => setState(() => _visualStyle = v),
            ),
          ),
          const SizedBox(height: 20),
          _buildSection(
            title: 'Actions',
            subtitle: 'Menú "+" del compositor · hiddenActions oculta por id',
            child: _buildChipGroup<bool>(
              options: {
                false: ('Todas', Icons.check_circle_outline_rounded),
                true: ('Sin "Nueva conversación"', Icons.visibility_off_outlined),
              },
              selected: _hideNewConversation,
              onSelected: (v) => setState(() => _hideNewConversation = v),
            ),
          ),
          const SizedBox(height: 20),
          _buildSection(
            title: 'Borrado en el servidor',
            subtitle: '`visitor_can_delete` de la config · lo resuelve el backend',
            child: _buildChipGroup<bool>(
              options: {
                true: ('Habilitado', Icons.delete_outline_rounded),
                false: ('Deshabilitado', Icons.block_rounded),
              },
              selected: _visitorCanDelete,
              // La config se lee al inicializar, así que el flag nuevo entra
              // con un controller nuevo (y con el historial de demo de vuelta).
              onSelected: (v) {
                if (v == _visitorCanDelete) return;
                _visitorCanDelete = v;
                _resetDemo();
              },
            ),
          ),
          const SizedBox(height: 20),
          _buildSection(
            title: 'Entrada de voz',
            subtitle: 'Botón de micrófono en el compositor · `audio` en FindChatWidget',
            child: _buildChipGroup<_Voice>(
              options: {
                _Voice.off: ('Sin micrófono', Icons.mic_off_outlined),
                _Voice.scripted: ('Motor ficticio', Icons.theaters_outlined),
                _Voice.browser: ('Navegador', Icons.mic_none_rounded),
              },
              selected: _voice,
              onSelected: (v) => setState(() => _voice = v),
            ),
          ),
          if (_voice != _Voice.off) ...[
            const SizedBox(height: 20),
            _buildSection(
              title: 'Qué pasa con el dictado',
              subtitle: 'transcriptMode: al campo para revisar, enviar solo, o confirmar antes',
              child: _buildChipGroup<FindChatTranscriptMode>(
                options: {
                  FindChatTranscriptMode.editInComposer: ('Al campo', Icons.edit_outlined),
                  FindChatTranscriptMode.autoSend: ('Enviar solo', Icons.send_outlined),
                  FindChatTranscriptMode.preview: ('Confirmar', Icons.fact_check_outlined),
                },
                selected: _transcriptMode,
                onSelected: (v) => setState(() => _transcriptMode = v),
              ),
            ),
          ],
          const SizedBox(height: 20),
          _buildSection(
            title: 'Estado inicial',
            subtitle: 'Título, bajada y atajos mientras no hay mensajes · `emptyState`',
            child: _buildChipGroup<bool>(
              options: {
                true: ('Con atajos', Icons.auto_awesome_mosaic_outlined),
                false: ('Bienvenida clásica', Icons.chat_bubble_outline_rounded),
              },
              selected: _showEmptyState,
              onSelected: (v) => setState(() => _showEmptyState = v),
            ),
          ),
          const SizedBox(height: 20),
          _buildSection(
            title: 'Eventos',
            subtitle: 'Lo que llega por `callbacks.onEvent` — un solo hook para analítica',
            child: _buildEventsCard(),
          ),
          const SizedBox(height: 28),
          _buildSection(
            title: 'Programmatic Control',
            subtitle: 'Call these methods from anywhere in your app',
            child: _buildControlButtons(),
          ),
          if (_backend == _Backend.demo) ...[
            const SizedBox(height: 28),
            _buildSection(
              title: 'Demo backend',
              subtitle: 'Escenarios que se disparan por palabras clave en el mensaje',
              child: _buildDemoCard(),
            ),
          ],
          const SizedBox(height: 28),
          _buildHintCard(),
        ],
      ),
    );
  }

  Widget _buildSection({
    required String title,
    String? subtitle,
    required Widget child,
  }) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          title.toUpperCase(),
          style: const TextStyle(
            fontSize: 11,
            fontWeight: FontWeight.w600,
            color: Color(0xFF596780),
            letterSpacing: 0.8,
          ),
        ),
        if (subtitle != null) ...[
          const SizedBox(height: 2),
          Text(
            subtitle,
            style: const TextStyle(fontSize: 12, color: Color(0xFF8792A2)),
          ),
        ],
        const SizedBox(height: 10),
        child,
      ],
    );
  }

  Widget _buildChipGroup<T>({
    required Map<T, (String, IconData)> options,
    required T selected,
    required ValueChanged<T> onSelected,
  }) {
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: options.entries.map((e) {
        final isActive = e.key == selected;
        return GestureDetector(
          onTap: () => onSelected(e.key),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 180),
            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
            decoration: BoxDecoration(
              color: isActive ? const Color(0xFF635BFF) : Colors.white,
              borderRadius: BorderRadius.circular(10),
              border: Border.all(
                color: isActive ? const Color(0xFF635BFF) : const Color(0xFFE3E8EF),
              ),
              boxShadow: isActive
                  ? [
                      BoxShadow(
                        color: const Color(0xFF635BFF).withValues(alpha: 0.25),
                        blurRadius: 8,
                        offset: const Offset(0, 2),
                      ),
                    ]
                  : [
                      BoxShadow(
                        color: Colors.black.withValues(alpha: 0.04),
                        blurRadius: 4,
                        offset: const Offset(0, 1),
                      ),
                    ],
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(
                  e.value.$2,
                  size: 16,
                  color: isActive ? Colors.white : const Color(0xFF596780),
                ),
                const SizedBox(width: 6),
                Text(
                  e.value.$1,
                  style: TextStyle(
                    fontSize: 13,
                    fontWeight: FontWeight.w500,
                    color: isActive ? Colors.white : const Color(0xFF0A2540),
                  ),
                ),
              ],
            ),
          ),
        );
      }).toList(),
    );
  }

  Widget _buildControlButtons() {
    return _buildCard(
      child: Wrap(
        spacing: 8,
        runSpacing: 8,
        children: [
          _buildActionBtn('open()', Icons.open_in_new_rounded, () => _controller.open()),
          _buildActionBtn('close()', Icons.close_rounded, () => _controller.close()),
          _buildActionBtn('toggle()', Icons.swap_vert_rounded, () => _controller.toggle()),
          _buildActionBtn(
            'sendMessage()',
            Icons.send_rounded,
            () => _controller.sendMessage('Hello from the example app'),
            outlined: true,
          ),
          _buildActionBtn(
            'clearConversation()',
            Icons.layers_clear_rounded,
            () => _controller.clearConversation(),
            outlined: true,
          ),
          // Los dos de abajo sí hablan con el servidor (el demo backend los
          // implementa): borran o vacían el hilo, no solo la pantalla.
          _buildActionBtn(
            'deleteConversation()',
            Icons.delete_outline_rounded,
            () => _controller.deleteConversation(),
            outlined: true,
          ),
          _buildActionBtn(
            'emptyConversation()',
            Icons.cleaning_services_rounded,
            () => _controller.emptyConversation(),
            outlined: true,
          ),
        ],
      ),
    );
  }

  Widget _buildDemoCard() {
    const keywords = [
      ('largo', 'respuesta extensa, para probar el scroll durante el streaming'),
      ('markdown', 'títulos, listas, código, cita y tabla'),
      ('lento', 'el asistente tarda 5 s en empezar a responder'),
      ('error', 'el flujo falla a mitad del turno (evento SSE error)'),
      ('falla', 'el servidor rechaza el mensaje con HTTP 500'),
    ];
    return _buildCard(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (word, description) in keywords)
            Padding(
              padding: const EdgeInsets.only(bottom: 6),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Container(
                    padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
                    decoration: BoxDecoration(
                      color: const Color(0xFFF0EEFF),
                      borderRadius: BorderRadius.circular(6),
                    ),
                    child: Text(
                      word,
                      style: const TextStyle(
                        fontSize: 12,
                        fontFamily: 'monospace',
                        color: Color(0xFF635BFF),
                      ),
                    ),
                  ),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      description,
                      style: const TextStyle(fontSize: 12, color: Color(0xFF596780)),
                    ),
                  ),
                ],
              ),
            ),
          const SizedBox(height: 6),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              _buildActionBtn('Reiniciar demo', Icons.restart_alt_rounded, _resetDemo),
              _buildActionBtn(
                "sendMessage('largo')",
                Icons.notes_rounded,
                () => _controller.sendMessage('largo'),
                outlined: true,
              ),
              _buildActionBtn(
                "sendMessage('markdown')",
                Icons.code_rounded,
                () => _controller.sendMessage('markdown'),
                outlined: true,
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildCard({required Widget child}) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: const Color(0xFFE3E8EF)),
      ),
      child: child,
    );
  }

  Widget _buildActionBtn(
    String label,
    IconData icon,
    VoidCallback onPressed, {
    bool outlined = false,
  }) {
    return Material(
      color: outlined ? Colors.transparent : const Color(0xFF0A2540),
      borderRadius: BorderRadius.circular(8),
      child: InkWell(
        onTap: onPressed,
        borderRadius: BorderRadius.circular(8),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
          decoration: outlined
              ? BoxDecoration(
                  borderRadius: BorderRadius.circular(8),
                  border: Border.all(color: const Color(0xFFE3E8EF)),
                )
              : null,
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(
                icon,
                size: 14,
                color: outlined ? const Color(0xFF596780) : Colors.white,
              ),
              const SizedBox(width: 6),
              Text(
                label,
                style: TextStyle(
                  fontSize: 12,
                  fontWeight: FontWeight.w500,
                  fontFamily: 'monospace',
                  color: outlined ? const Color(0xFF0A2540) : Colors.white,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildHintCard() {
    final hint = _mode == FindChatMode.floating
        ? 'Tap the floating bubble in the bottom-right corner to open the chat.'
        : _mode == FindChatMode.drawer
            ? 'Use the open() button above or controller.open() to reveal the drawer panel.'
            : 'The chat renders inline as a regular widget (on the right, or below the '
                'display modes on narrow screens). Pick Floating or Drawer to go back to the '
                'overlay.';

    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: const Color(0xFFFFF8E1),
        borderRadius: BorderRadius.circular(10),
        border: Border.all(color: const Color(0xFFFFE082)),
      ),
      child: Row(
        children: [
          const Icon(Icons.lightbulb_outline_rounded, size: 16, color: Color(0xFFF9A825)),
          const SizedBox(width: 10),
          Expanded(
            child: Text(
              hint,
              style: const TextStyle(fontSize: 12, color: Color(0xFF5D4037)),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildEmbeddedChat({EdgeInsets padding = const EdgeInsets.all(24)}) {
    return Padding(
      padding: padding,
      child: Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: const Color(0xFFE3E8EF)),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withValues(alpha: 0.06),
              blurRadius: 16,
              offset: const Offset(0, 4),
            ),
          ],
        ),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(16),
          child: _buildChat(FindChatMode.embedded),
        ),
      ),
    );
  }
}
2
likes
160
points
212
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter SDK for the Find AI chat assistant. Drop-in widget with floating, drawer and embedded modes, SSE streaming, and signed-mode visitor auth.

Homepage
Repository (GitHub)

License

MIT (license)

Dependencies

flutter, flutter_markdown_plus, http, shared_preferences, speech_to_text, web

More

Packages that depend on find_ai_chat