find_ai_chat 0.8.0
find_ai_chat: ^0.8.0 copied to clipboard
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),
),
),
);
}
}