# Flutter (0.3.0) > Step-by-step guide to integrate the RevRag Flutter embed SDK (voice agent, EmbedWidget, navigation-aware visibility, events, call control, and native platform setup). URL: /embed/integration/flutter/v0.3.0 Markdown: /embed/integration/flutter/v0.3.0.md # Embed Flutter SDK [#embed-flutter-sdk] Step-by-step guide to integrate the RevRag Flutter embed SDK. | | | | ------------ | -------------------------------------------------------------------------------------------------------- | | **Package** | `embed_flutter` | | **Version** | `0.3.0` | | **Requires** | Flutter ≥ 3.29.0 / Dart ≥ 3.7.0 | | **Covers** | Voice agent, `EmbedWidget`, navigation-aware visibility, events, call control, and native platform setup | **Native microphone setup is required on both Android and iOS.** Skipping it is the most common source of "the mic dialog never appears" and silent voice failures. See [Android Setup](#11-android-setup) and [iOS Setup](#12-ios-setup). **Follow this guide in order the first time you integrate:** | Step | What you do | Details | | ------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------- | | 1. **Install** | Add `embed_flutter` to your `pubspec.yaml` | [§1](#1-installation) | | 2. **Native setup** | Android and iOS microphone setup — required | [§1.1](#11-android-setup) · [§1.2](#12-ios-setup) | | 3. **Initialize** | Call `embedInitialize()` **before** `runApp()` | [§3](#3-initialization) | | 4. **Wrap your app** | Make `EmbedWidget` the **outermost** widget | [§4](#4-wrapping-your-app-with-embedwidget) | | 5. **Connect navigation** | Tell the SDK when the route changes, so it can show or hide the agent button | [§5](#5-navigator-integration) | | 6. **Identify** | Send `USER_DATA` once you have a logged-in user ID — the only required event | [§6.1](#61-user_data-required) | *** ## Table of Contents [#table-of-contents] 1. [Installation](#1-installation) * [Android Setup](#11-android-setup) * [iOS Setup](#12-ios-setup) 2. [How the SDK Works (Mental Model)](#2-how-the-sdk-works-mental-model) 3. [Initialization](#3-initialization) 4. [Wrapping Your App with EmbedWidget](#4-wrapping-your-app-with-embedwidget) 5. [Navigator Integration](#5-navigator-integration) * [MaterialApp with Named Routes](#51-materialapp-with-named-routes) * [MaterialApp with onGenerateRoute](#52-materialapp-with-ongenerateroute) * [GoRouter](#53-gorouter) * [GoRouter with ShellRoute (Bottom Tab Navigator)](#54-gorouter-with-shellroute-bottom-tab-navigator) * [MaterialApp Bottom TabNavigator (BottomNavigationBar)](#55-materialapp-bottom-tabnavigator-bottomnavigationbar) * [Stable Screen Names](#56-stable-screen-names) 6. [Sending Events](#6-sending-events) * [USER\_DATA (Required)](#61-user_data-required) * [SCREEN\_STATE (Optional)](#62-screen_state-optional) * [CUSTOM\_EVENT (Optional)](#63-custom_event-optional) * [ANALYTICS\_DATA (Optional)](#64-analytics_data-optional) 7. [EmbedWidget Parameters Reference](#7-embedwidget-parameters-reference) * [Route Match Modes](#71-route-match-modes) * [Visibility Groups (per-screen delay & continuity)](#72-visibility-groups) 8. [Advanced: Widget Visibility and Positioning](#8-advanced-widget-visibility-and-positioning) * [Sensitive screens and dialogs](#sensitive-screens-and-dialogs) 9. [Advanced: Listening to SDK Events](#9-advanced-listening-to-sdk-events) 10. [Utility Functions](#10-utility-functions) 11. [Complete End-to-End Example](#11-complete-end-to-end-example) 12. [Safety — the SDK cannot crash your app](#12-safety--the-sdk-cannot-crash-your-app) 13. [Troubleshooting](#13-troubleshooting) *** ## 1. Installation [#1-installation] Add the package to your `pubspec.yaml`: ```yaml dependencies: embed_flutter: ^0.3.0 ``` Then fetch: ```bash flutter pub get ``` `embed_flutter` 0.3.0 needs **Flutter 3.29 or newer** (Dart 3.7+). Check with `flutter --version`. If your app can't move to Flutter 3.29 yet, pin `embed_flutter: 0.2.0` (exactly, without `^`). See the [release notes](/embed/integration/flutter/flutter-releases). **Read this before pinning versions.** Supported Flutter/Dart range, Android/iOS platform floors, the pinned version of every dependency the SDK ships (`livekit_client`, `permission_handler`, …), and the checklist that makes an install actually work. ### 1.1 Android Setup [#11-android-setup] Open `android/app/src/main/AndroidManifest.xml` and add: ```xml ``` ### 1.2 iOS Setup [#12-ios-setup] **Step 1 — Info.plist** Open `ios/Runner/Info.plist` and add: ```xml NSMicrophoneUsageDescription This app needs microphone access for voice calls. ``` **Step 2 — Podfile** Open `ios/Podfile` and add the `PERMISSION_MICROPHONE=1` macro inside the `post_install` block: ```ruby post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', 'PERMISSION_MICROPHONE=1', ] end end end ``` Without `PERMISSION_MICROPHONE=1` the system permission dialog will never appear and the permission will always be reported as denied. After editing the Podfile, run: ```bash cd ios && pod install && cd .. ``` *** ## 2. How the SDK Works (Mental Model) [#2-how-the-sdk-works-mental-model] Understanding these three concepts up front makes integration straightforward: | Concept | Description | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **EmbedWidget** | A full-screen overlay wrapper placed at the very top of your widget tree. It renders the floating AI agent button and manages the live voice session. | | **EmbedNavigatorObserver / EmbedRouteListener** | Tells the SDK which screen the user is on so it knows whether to show the agent button. | | **enabledRoutes** | A list of route names where the agent button should appear. Only routes listed here will show the agent. | **Order of operations every time:** ``` main() └─ embedInitialize(apiKey: …, appVersion: …) ← before runApp └─ runApp( EmbedWidget( ← outermost widget enabledRoutes: ['home', 'product', 'cart'], child: MaterialApp / Router( navigatorObservers: [EmbedNavigatorObserver()], ← required ... ), ), ) After authentication → embedEvent(USER_DATA, ...) ← activates the agent ``` *** ## 3. Initialization [#3-initialization] Call `embedInitialize()` **before** `runApp()`. ```dart import 'package:flutter/material.dart'; import 'package:embed_flutter/embed_flutter.dart'; void main() { embedInitialize( apiKey: 'your-api-key', embedUrl: 'https://embed.revrag.ai', // optional; defaults to revrag.ai appVersion: '2.4.1', // REQUIRED — the only app-version intake onResult: (bool success, String? error) { // Called after the SDK prefetches the UI config from the server. if (!success) { print('Embed init failed: $error'); } }, ); runApp(const MyApp()); } ``` **App version** is **required** and passed to `embedInitialize(appVersion:)` — that is its **only** intake (no auto-derivation, no post-init setter). It is sent with every event. *** ## 4. Wrapping Your App with EmbedWidget [#4-wrapping-your-app-with-embedwidget] `EmbedWidget` must be the **outermost** widget — it wraps your `MaterialApp` / `MaterialApp.router` / `CupertinoApp`. ```dart class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, // List the routes where the agent button should appear enabledRoutes: const ['home', 'product_list', 'product_detail', 'cart'], child: MaterialApp( navigatorObservers: [EmbedNavigatorObserver()], // ... rest of your app ), ); } } ``` The agent button appears **only** on routes listed in `enabledRoutes`. On all other routes (e.g. splash, login, settings) it is hidden automatically. Alternatively, to show the agent on **every** screen except a few: ```dart EmbedWidget( showEmbedWidget: true, showOnAllRoutes: true, disabledRoutes: const ['splash', 'login', 'otp'], child: MaterialApp( navigatorObservers: [EmbedNavigatorObserver()], ), ) ``` **`disabledRoutes` is the only list that keeps the agent from looking at a screen.** `enabledRoutes`, `showEmbedWidget`, the platform allowlist and the dashboard data-capture setting control where the button shows and what telemetry is sent. They do **not** stop an action the agent is already running from reading the screen. Put every screen the agent must never see (login, OTP, payment, regulated data) in `disabledRoutes`. See [Sensitive screens and dialogs](#sensitive-screens-and-dialogs). *** ## 5. Navigator Integration [#5-navigator-integration] This is the most important integration step. The SDK needs to know when the route changes so it can show or hide the agent button. The approach differs slightly depending on which navigator you use. ### 5.1 MaterialApp with Named Routes [#51-materialapp-with-named-routes] The simplest case — named routes declared directly in `MaterialApp.routes`. ```dart import 'package:flutter/material.dart'; import 'package:embed_flutter/embed_flutter.dart'; void main() { embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0'); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, enabledRoutes: const ['product_list', 'product_detail', 'cart'], child: MaterialApp( // ✅ Required — tracks route changes navigatorObservers: [EmbedNavigatorObserver()], initialRoute: '/', routes: { '/': (ctx) => const SplashScreen(), 'home': (ctx) => const HomeScreen(), 'product_list': (ctx) => const ProductListScreen(), 'product_detail': (ctx) => const ProductDetailScreen(), 'cart': (ctx) => const CartScreen(), 'checkout': (ctx) => const CheckoutScreen(), }, ), ); } } ``` **Navigating between routes:** ```dart // Push Navigator.pushNamed(context, 'product_list'); // Push with arguments Navigator.pushNamed(context, 'product_detail', arguments: {'id': 42}); // Replace (useful for tabs — see section 5.5) Navigator.pushReplacementNamed(context, 'cart'); ``` `EmbedNavigatorObserver` intercepts every push, pop, and replace and shows or hides the agent button based on whether the new route is in `enabledRoutes`. *** ### 5.2 MaterialApp with onGenerateRoute [#52-materialapp-with-ongenerateroute] When routes carry parameters you typically use `onGenerateRoute`. Everything else is identical. ```dart class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, enabledRoutes: const ['welcome', 'personal_info', 'address', 'review'], child: MaterialApp( navigatorObservers: [EmbedNavigatorObserver()], // ✅ required home: const SplashScreen(), onGenerateRoute: (settings) { switch (settings.name) { case 'welcome': return MaterialPageRoute( settings: settings, // ← always pass settings so the observer reads the name builder: (_) => const WelcomeScreen(), ); case 'personal_info': final args = settings.arguments as Map?; return MaterialPageRoute( settings: settings, builder: (_) => PersonalInfoScreen(data: args), ); case 'address': return MaterialPageRoute( settings: settings, builder: (_) => const AddressScreen(), ); case 'review': return MaterialPageRoute( settings: settings, builder: (_) => const ReviewScreen(), ); default: return null; } }, ), ); } } ``` Always pass `settings: settings` to `MaterialPageRoute`. Without it the route name is `null` and the SDK cannot determine whether to show the agent. *** ### 5.3 GoRouter [#53-gorouter] GoRouter uses the `name` field of each `GoRoute` for matching. Pass the observer in `GoRouter.observers`. ```dart import 'package:go_router/go_router.dart'; import 'package:embed_flutter/embed_flutter.dart'; // ── Router definition ───────────────────────────────────────────────────────── final _router = GoRouter( initialLocation: '/splash', // ✅ Add observer here observers: [EmbedNavigatorObserver()], routes: [ GoRoute( path: '/splash', name: 'splash', builder: (context, state) => const SplashScreen(), ), GoRoute( path: '/home', name: 'home', builder: (context, state) => const HomeScreen(), ), GoRoute( path: '/product/:id', name: 'product_detail', builder: (context, state) { final id = state.pathParameters['id']!; return ProductDetailScreen(id: id); }, ), GoRoute( path: '/cart', name: 'cart', builder: (context, state) => const CartScreen(), ), GoRoute( path: '/payment', name: 'payment', builder: (context, state) => const PaymentScreen(), ), ], ); // ── main ────────────────────────────────────────────────────────────────────── void main() { embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0'); runApp(const MyApp()); } // ── App widget ──────────────────────────────────────────────────────────────── class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, // Use the `name:` field from GoRoute — not the path enabledRoutes: const ['home', 'product_detail', 'cart', 'payment'], child: MaterialApp.router( routerConfig: _router, ), ); } } ``` **Navigating with GoRouter:** ```dart // By name context.goNamed('cart'); // By path context.go('/cart'); // Push (adds to stack) context.pushNamed('product_detail', pathParameters: {'id': '42'}); ``` #### GoRouter with `nameExtractor` (path-based matching) [#gorouter-with-nameextractor-path-based-matching] If you prefer to match on URL paths instead of route names, supply a `nameExtractor` to the observer: ```dart EmbedNavigatorObserver( nameExtractor: (route) { final name = route.settings.name ?? ''; if (name.startsWith('/product/')) return 'product_detail'; if (name == '/cart') return 'cart'; return null; // null = SDK ignores this push }, ) ``` Then use those extracted names in `enabledRoutes` as usual. *** ### 5.4 GoRouter with ShellRoute (Bottom Tab Navigator) [#54-gorouter-with-shellroute-bottom-tab-navigator] `ShellRoute` keeps a persistent shell (e.g. a bottom navigation bar) while swapping child routes. The challenge is that `ShellRoute` children run inside a **nested navigator** — the top-level observer does not fire for them. **Solution:** Wrap each tab's screen with `EmbedRouteListener`. This widget notifies the SDK of the current screen whenever the tab is displayed. ```dart import 'package:go_router/go_router.dart'; import 'package:embed_flutter/embed_flutter.dart'; // ── Router ──────────────────────────────────────────────────────────────────── final _router = GoRouter( initialLocation: '/home', observers: [EmbedNavigatorObserver()], // catches pushes outside the shell routes: [ ShellRoute( builder: (context, state, child) => AppShell(child: child), routes: [ GoRoute( path: '/home', name: 'home', builder: (context, state) => const HomeTab(), ), GoRoute( path: '/offers', name: 'offers', builder: (context, state) => const OffersTab(), ), GoRoute( path: '/profile', name: 'profile', builder: (context, state) => const ProfileTab(), ), // Sub-route inside a tab (pushed onto the nested navigator) GoRoute( path: '/offers/detail/:id', name: 'offer_detail', builder: (context, state) { final id = state.pathParameters['id']!; return OfferDetailScreen(id: id); }, ), ], ), ], ); // ── Shell scaffold ──────────────────────────────────────────────────────────── class AppShell extends StatelessWidget { final Widget child; const AppShell({super.key, required this.child}); @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.toString(); int currentIndex = 0; if (location.startsWith('/offers')) currentIndex = 1; if (location.startsWith('/profile')) currentIndex = 2; return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( currentIndex: currentIndex, onTap: (i) { switch (i) { case 0: context.go('/home'); break; case 1: context.go('/offers'); break; case 2: context.go('/profile'); break; } }, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.local_offer), label: 'Offers'), BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'), ], ), ); } } // ── Tab screens ─────────────────────────────────────────────────────────────── // Wrap each tab with EmbedRouteListener so the SDK knows which tab is active. class HomeTab extends StatelessWidget { const HomeTab({super.key}); @override Widget build(BuildContext context) { return EmbedRouteListener( routeName: 'home', // ✅ must match the name in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Home')), body: const Center(child: Text('Home content')), ), ); } } class OffersTab extends StatelessWidget { const OffersTab({super.key}); @override Widget build(BuildContext context) { return EmbedRouteListener( routeName: 'offers', // ✅ agent visible — listed in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Offers')), body: const Center(child: Text('Browse offers')), ), ); } } class ProfileTab extends StatelessWidget { const ProfileTab({super.key}); @override Widget build(BuildContext context) { return EmbedRouteListener( routeName: 'profile', // agent hidden — not listed in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Profile')), body: const Center(child: Text('Your profile')), ), ); } } // ── main ────────────────────────────────────────────────────────────────────── void main() { embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0'); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, // 'profile' is intentionally excluded — agent hidden there enabledRoutes: const ['home', 'offers', 'offer_detail'], child: MaterialApp.router(routerConfig: _router), ); } } ``` **Key rules for ShellRoute:** | Rule | Why | | ------------------------------------------------------------------ | ------------------------------------------------------------------ | | Add `EmbedNavigatorObserver()` to the top-level `GoRouter` | Catches pushes onto the root navigator (screens outside the shell) | | Wrap each tab's screen with `EmbedRouteListener(routeName: '...')` | Tells the SDK which tab is currently visible | | The `routeName` must match what you put in `enabledRoutes` | Otherwise the SDK cannot determine visibility | *** ### 5.5 MaterialApp Bottom TabNavigator (BottomNavigationBar) [#55-materialapp-bottom-tabnavigator-bottomnavigationbar] When using `BottomNavigationBar` with a plain `MaterialApp`, push a new named route for each tab using `Navigator.pushReplacementNamed` so the route stack stays shallow and the observer fires on every tab switch. ```dart // ── main.dart ───────────────────────────────────────────────────────────────── void main() { embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0'); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, // Agent appears only on the Plans and Exclusive tabs enabledRoutes: const ['plans_tab', 'exclusive_tab'], child: MaterialApp( navigatorObservers: [EmbedNavigatorObserver()], // ✅ required home: const HomeTab(), onGenerateRoute: (settings) { switch (settings.name) { case 'home_tab': return MaterialPageRoute(settings: settings, builder: (_) => const HomeTab()); case 'plans_tab': return MaterialPageRoute(settings: settings, builder: (_) => const PlansTab()); case 'exclusive_tab': return MaterialPageRoute(settings: settings, builder: (_) => const ExclusiveTab()); case 'profile_tab': return MaterialPageRoute(settings: settings, builder: (_) => const ProfileTab()); default: return null; } }, ), ); } } // ── Shared bottom nav scaffold ──────────────────────────────────────────────── class AppNavBar extends StatelessWidget { final Widget child; final int currentIndex; const AppNavBar({super.key, required this.child, required this.currentIndex}); static const _routes = ['home_tab', 'plans_tab', 'exclusive_tab', 'profile_tab']; void _onTap(BuildContext context, int index) { if (index == currentIndex) return; // pushReplacementNamed so the back button doesn't cycle through tab history // and EmbedNavigatorObserver fires didReplace → SDK updates its route state Navigator.pushReplacementNamed(context, _routes[index]); } @override Widget build(BuildContext context) { return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( currentIndex: currentIndex, type: BottomNavigationBarType.fixed, selectedItemColor: Colors.blue[700], unselectedItemColor: Colors.grey[600], onTap: (i) => _onTap(context, i), items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), BottomNavigationBarItem(icon: Icon(Icons.description), label: 'Plans'), BottomNavigationBarItem(icon: Icon(Icons.star), label: 'Exclusive'), BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'), ], ), ); } } // ── Tab screens ─────────────────────────────────────────────────────────────── class HomeTab extends StatelessWidget { const HomeTab({super.key}); @override Widget build(BuildContext context) { return AppNavBar( currentIndex: 0, // Agent hidden — 'home_tab' is not in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Home')), body: const Center(child: Text('Home content')), ), ); } } class PlansTab extends StatelessWidget { const PlansTab({super.key}); @override Widget build(BuildContext context) { return AppNavBar( currentIndex: 1, // ✅ Agent visible — 'plans_tab' is in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Plans')), body: const Center(child: Text('Browse plans')), ), ); } } class ExclusiveTab extends StatelessWidget { const ExclusiveTab({super.key}); @override Widget build(BuildContext context) { return AppNavBar( currentIndex: 2, // ✅ Agent visible — 'exclusive_tab' is in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Exclusive')), body: const Center(child: Text('Exclusive deals')), ), ); } } class ProfileTab extends StatelessWidget { const ProfileTab({super.key}); @override Widget build(BuildContext context) { return AppNavBar( currentIndex: 3, // Agent hidden — 'profile_tab' is not in enabledRoutes child: Scaffold( appBar: AppBar(title: const Text('Profile')), body: const Center(child: Text('Your profile')), ), ); } } ``` **How it works:** * `Navigator.pushReplacementNamed` fires `didReplace` on `EmbedNavigatorObserver`, which updates the current route in the SDK. * The SDK checks the new route name against `enabledRoutes` and shows or hides the agent button — no extra code needed. *** ### 5.6 Stable Screen Names [#56-stable-screen-names] Separately from button visibility, the SDK files everything it captures under a **screen name**. The agent uses it to know which screen it is on and whether a tap changed screens, and the on-device screen cache is keyed by it. From 0.3.0 the name is built only from stable signals, so it is the **same in debug, release and `--obfuscate` builds**. Class names are never used. The SDK resolves the screen name in this order: | Priority | Source | Example name | | -------- | --------------------------------------------------------------------------------------------------------- | ----------------- | | 1 | The current route name: `RouteSettings.name`, your `nameExtractor`, or `EmbedRouteListener(routeName:)` | `checkout` | | 2 | If an `IndexedStack` / `PageView` tab shell is in front, the active tab is appended | `home/portfolio` | | 3 | An unnamed top page: its first short heading (an `AppBar` title of ≤ 4 words, ≤ 32 characters, no digits) | `my_orders` | | 4 | An unnamed pushed page with no other signal: its depth above the last named route | `checkout/page_1` | | 5 | Otherwise the active visibility group, then `unknown_flow` | `unknown_flow` | A tab inside an `IndexedStack` is named from, in order: a `ValueKey` on the tab's root widget, a `Semantics(identifier:)` at the top of the tab, then the tab's first heading. A `PageView` page is named only from a `ValueKey` on the page child. Names are slugged: `"My Orders"` becomes `my_orders`. **What to do in your app:** 1. **Name every route.** Pass `settings: RouteSettings(name: '…')` to every `MaterialPageRoute` (and in `onGenerateRoute`), and set `name:` on every `GoRoute`. Seeing `…/page_1` in the logs means a page was pushed without a name. 2. **Give each tab root a `ValueKey`**, or wrap each tab body in `EmbedRouteListener`: ```dart IndexedStack( index: currentIndex, children: const [ HomeTab(key: ValueKey('home')), PortfolioTab(key: ValueKey('portfolio')), OrdersTab(key: ValueKey('orders')), ], ) ``` Don't rely on headings for tabs whose title contains a number, a greeting or the user's name. Those headings are rejected as names. 3. **Name sensitive dialogs and bottom sheets** with `routeSettings: RouteSettings(name: '…')` so you can list them in `disabledRoutes` (see [Sensitive screens and dialogs](#sensitive-screens-and-dialogs)). 4. **Never build names from `runtimeType` or `toString()`** in a custom `nameExtractor`. Release builds strip `toString()` and `--obfuscate` renames classes. *** ## 6. Sending Events [#6-sending-events] ### 6.1 USER\_DATA (Required) [#61-user_data-required] This is the **only required event**. Send it after you have a logged-in user ID. It activates voice features and associates all future analytics with that user. ```dart embedEvent( EventKeys.USER_DATA, UserEventPayload( app_user_id: 'user_12345', // required — your unique user identifier data: { 'name': 'Priya Sharma', // optional extra fields sent to the agent 'email': 'priya@example.com', 'plan': 'premium', }, ), ); ``` **When to call it:** | Scenario | When to call | | ------------------- | ----------------------------------------------------- | | Auth flow | Immediately after `signIn()` / token refresh succeeds | | No auth (demo apps) | In `initState` of your first meaningful screen | The SDK queues this event internally if the server config hasn't loaded yet and flushes it automatically — you do not need to wait or retry. *** ### 6.2 SCREEN\_STATE (Optional) [#62-screen_state-optional] Sends additional context about the current screen to the agent. Useful when you want the agent to know which step of a multi-step form the user is on, or when multiple routes share one screen widget. ```dart embedEvent( EventKeys.SCREEN_STATE, ScreenEventPayload( screen: 'loan_application_step_2', data: { 'step': 2, 'total_steps': 5, 'category': 'home_loan', }, ), ); ``` Call it in `initState` or whenever meaningful context changes (e.g. a stepper advances). *** ### 6.3 CUSTOM\_EVENT (Optional) [#63-custom_event-optional] Captures any interaction you want to relay to the agent — button taps, option selections, modal opens, etc. ```dart embedEvent( EventKeys.CUSTOM_EVENT, CustomEventPayload( data: { 'context': 'plan_selection', 'action': 'plan_selected', 'plan_id': 'gold_12m', 'price': 1499, 'currency': 'INR', }, ), ); ``` *** ### 6.4 ANALYTICS\_DATA (Optional) [#64-analytics_data-optional] Like `CUSTOM_EVENT` but requires a named event identifier. Use this for conversion events, funnel steps, or any event you need to categorise by a fixed name on the backend. ```dart embedEvent( EventKeys.ANALYTICS_DATA, AnalyticsDataEventPayload( event_name: 'payment_completed', data: { 'amount': 4999, 'currency': 'INR', 'payment_method': 'upi', 'transaction_id': 'txn_abc123', }, ), ); ``` **Event reference table:** | Event | Class | `event_name` required? | Typical use | | ---------------- | --------------------------- | ---------------------- | --------------------------------------------- | | `USER_DATA` | `UserEventPayload` | — | Identifying the user and activating the agent | | `SCREEN_STATE` | `ScreenEventPayload` | — | Providing screen or step context to the agent | | `CUSTOM_EVENT` | `CustomEventPayload` | No | Ad-hoc interactions and selections | | `ANALYTICS_DATA` | `AnalyticsDataEventPayload` | **Yes** | Named funnel and conversion events | *** ## 7. EmbedWidget Parameters Reference [#7-embedwidget-parameters-reference] | Parameter | Type | Default | Description | | --------------------------- | ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `child` | `Widget` | — | **Required.** Your `MaterialApp` / `MaterialApp.router` | | `showEmbedWidget` | `bool` | `true` | Master visibility toggle — set to `false` to hide the agent everywhere | | `enabledRoutes` | `List` | `[]` | Route names where the agent button should appear | | `showOnAllRoutes` | `bool` | `false` | Show on every route. Use with `disabledRoutes` to exclude specific screens | | `disabledRoutes` | `List` | `[]` | Routes where the agent is always hidden (takes priority over `showOnAllRoutes`); entering one ends a live call. Also the **only** list that keeps the agent from looking at a screen, and accepts named dialogs / bottom sheets (see [Sensitive screens and dialogs](#sensitive-screens-and-dialogs)) | | `routeMatchMode` | `RouteMatchMode` | `exact` | How route names are compared — see section 7.1 | | `onPermissionStatusChanged` | `void Function(bool)?` | — | Called after the mic permission dialog — `true` = granted, `false` = denied | | `onBackRequested` | `Future Function()?` | — | First-chance handler for the agent's `back` mission (rung 1 of the back ladder) | | `embedButtonDelayMs` | `int` | `0` | Delay (ms) before the FAB appears after landing on an enabled route | | `continuity` | `EmbedButtonContinuity?` | `reset` | Whether the FAB hides/re-shows on navigation between enabled routes (`keepVisible` / `reset`) | | `buttonDelay` | `EmbedButtonDelay?` | — | Pre-show delay + its `EmbedButtonDelayPolicy` (how often the delay applies) | | `buttonVisibility` | `EmbedButtonVisibilityConfig?` | — | Per-screen visibility **groups** — different continuity/delay per set of screens (see [§7.2](#72-visibility-groups)) | | `usePlatformRouteAllowlist` | `bool` | `false` | Opt in to the manager (dashboard) route allowlist instead of `enabledRoutes`. Controls where the button shows; it does not keep the agent from looking at a screen | | `debug` | `bool` | `false` | Narrate the SDK's show/hide/call decisions to the console | ### 7.1 Route Match Modes [#71-route-match-modes] By default route names must match **exactly**. Use `routeMatchMode` to relax this: ```dart EmbedWidget( routeMatchMode: RouteMatchMode.startsWith, enabledRoutes: const ['/product'], // matches /product, /product/detail, /product/42, etc. child: ..., ) ``` | Mode | Behaviour | Example pattern | Matches | | --------------------------- | ----------------------------------- | --------------- | ---------------------------------------------------- | | `RouteMatchMode.exact` | Exact string match (default) | `'cart'` | only `'cart'` | | `RouteMatchMode.startsWith` | Route starts with the pattern | `'/product'` | `/product`, `/product/detail`, `/product/42` | | `RouteMatchMode.contains` | Route contains the pattern anywhere | `'product'` | `'product_list'`, `'new_product'`, `'/v2/product/3'` | Leading slashes are normalised — `'home'` and `'/home'` are treated identically. *** ### 7.2 Visibility Groups [#72-visibility-groups] By default, `continuity` / `buttonDelay` apply to **all** enabled routes. To give different sets of screens *different* visibility behavior — e.g. re-show the FAB on every onboarding step but keep it up throughout checkout — pass `buttonVisibility` with named **groups** (React Native parity): ```dart EmbedWidget( enabledRoutes: const ['home'], buttonVisibility: const EmbedButtonVisibilityConfig( defaultDelayMs: 0, // fallback for enabled screens in no group groups: [ EmbedButtonGroupConfig( id: 'onboarding', screens: ['welcome', 'kyc', 'review'], continuity: EmbedButtonContinuity.reset, // re-show each step delayMs: 1200, delayPolicy: EmbedButtonDelayPolicy.perScreen, ), EmbedButtonGroupConfig( id: 'checkout', screens: ['cart', 'payment', 'confirm'], continuity: EmbedButtonContinuity.keepVisible, // stay up through checkout delayMs: 300, delayPolicy: EmbedButtonDelayPolicy.oncePerGroupEntry, ), ], ), child: MyApp(), ) ``` * **Resolution:** for the current route, the first group whose `screens` contains it wins; its `continuity` / `delayMs` / `delayPolicy` override the widget-level values. A screen in no group falls back to `defaultDelayMs` → `buttonDelay` → `embedButtonDelayMs`, and the widget-level `continuity`. * **A group's `screens` are part of the allow-list** — listing a screen in a group makes the FAB show there even if it isn't in `enabledRoutes`. * **`delayPolicy`** scopes per group: `oncePerGroupEntry` delays once when you enter the group and skips while you stay in it; `oncePerAppSession` delays once per app launch; `perScreen` re-delays on every screen. Moving between two groups counts as a real group change. | `EmbedButtonGroupConfig` field | Type | Meaning | | ------------------------------ | ------------------------- | ----------------------------------------------------------- | | `id` | `String` | Stable group identity (the delay/continuity scope). | | `screens` | `List` | Route names in this group (matched with `routeMatchMode`). | | `continuity` | `EmbedButtonContinuity` | `keepVisible` / `reset` for this group. | | `delayMs` | `int?` | Pre-show delay for this group (falls back to widget-level). | | `delayPolicy` | `EmbedButtonDelayPolicy?` | `perScreen` / `oncePerGroupEntry` / `oncePerAppSession`. | *** ## 8. Advanced: Widget Visibility and Positioning [#8-advanced-widget-visibility-and-positioning] ### FAB Position [#fab-position] The floating button's position comes from the backend `widget_config` (`widget_position` / `right_padding` / `bottom_padding`), set from the Revrag dashboard. There are no host position props on `EmbedWidget`. ### Show on All Routes with Exclusions [#show-on-all-routes-with-exclusions] When you want the agent everywhere except a handful of screens: ```dart EmbedWidget( showEmbedWidget: true, showOnAllRoutes: true, disabledRoutes: const ['splash', 'login', 'otp_verification'], child: ..., ) ``` ### Sensitive screens and dialogs [#sensitive-screens-and-dialogs] From 0.3.0, `disabledRoutes` is the **one** list that keeps the agent away from a screen. On a screen in `disabledRoutes` nothing is captured or sent, and an agent action that would read it fails with `screen_not_capturable` instead. The following do **not** stop a running agent action from reading the screen. They only decide where the button shows and what telemetry is sent: * a route missing from `enabledRoutes` or from the platform allowlist * `showEmbedWidget: false`, or the button being hidden during a `buttonDelay` * the dashboard data-capture setting (`action_config.flags.dataCapture: false` stops telemetry only) Telemetry also continues while the button is hidden, matching the Android SDK. Screens in `disabledRoutes` are still never covered. If this conflicts with your data policy, contact Revrag. **Dialogs and bottom sheets** don't change the page route, so give sensitive ones a name and list that name in `disabledRoutes`: ```dart showDialog( context: context, routeSettings: const RouteSettings(name: 'otp_dialog'), builder: (_) => const OtpDialog(), ); showModalBottomSheet( context: context, routeSettings: const RouteSettings(name: 'card_entry_sheet'), builder: (_) => const CardEntrySheet(), ); EmbedWidget( enabledRoutes: const ['home', 'checkout'], disabledRoutes: const ['login', 'otp', 'otp_dialog', 'card_entry_sheet'], child: ..., ) ``` While a named dialog or sheet in `disabledRoutes` is open, capture is blocked, but the agent button is **not** hidden: it follows the page underneath. Its open and close analytics are still sent. Mark password, PIN and OTP fields `obscureText: true` as well. Their contents are redacted at capture on every screen, including screens that aren't in `disabledRoutes`. ### Microphone Permission Callback [#microphone-permission-callback] Handle the case where the user denies microphone access when starting a call: ```dart final _messengerKey = GlobalKey(); EmbedWidget( showEmbedWidget: true, enabledRoutes: const ['home', 'dashboard'], onPermissionStatusChanged: (bool granted) { if (!granted) { _messengerKey.currentState?.showSnackBar( const SnackBar( content: Text( 'Microphone access is required for voice calls. Please enable it in Settings.', ), ), ); } }, child: MaterialApp( scaffoldMessengerKey: _messengerKey, navigatorObservers: [EmbedNavigatorObserver()], home: const HomeScreen(), ), ) ``` **When is `onPermissionStatusChanged` called?** * After the user taps the agent button and the permission dialog is shown. * Before the call connection is established. * `true` = permission granted, the call will proceed. * `false` = permission denied or permanently denied. *** ## 9. Advanced: Listening to SDK Events [#9-advanced-listening-to-sdk-events] Subscribe to events emitted by the agent from anywhere in your app. ### Agent lifecycle events [#agent-lifecycle-events] ```dart import 'package:embed_flutter/embed_flutter.dart'; class MyScreen extends StatefulWidget { ... } class _MyScreenState extends State { late final String _handle; @override void initState() { super.initState(); // One-arg form = every agent event. Two-arg form subscribes to one: // embedOnAgent(SdkEventName.agentConversationStarted.value, (event) { ... }); _handle = embedOnAgent((Map event) { final name = SdkEventName.fromValue( (event['type'] ?? event['event_name']) as String?); switch (name) { case SdkEventName.agentConversationStarted: print('Voice call started'); case SdkEventName.agentConversationEnded: print('Voice call ended'); case SdkEventName.microphonePermissionAllowed: print('Mic granted'); case SdkEventName.microphonePermissionDenied: print('Mic denied'); default: break; } }); } @override void dispose() { embedOffAgent(_handle); // always unsubscribe to avoid memory leaks super.dispose(); } } ``` **Available `SdkEventName` constants:** | Constant | Value | Fired when | | ------------------------------------------ | --------------------------------- | ---------------------------------- | | `SdkEventName.agentVisible` | `'agent_visible'` | Agent FAB appears on screen | | `SdkEventName.agentConversationStarted` | `'agent_conversation_started'` | User starts a voice call | | `SdkEventName.agentConversationEnded` | `'agent_conversation_ended'` | Voice call ends | | `SdkEventName.popupMessageVisible` | `'popup_message_visible'` | Inactivity popup is shown | | `SdkEventName.agentTapToOpen` | `'agent_tap_to_open'` | Agent widget expanded | | `SdkEventName.agentTapToClose` | `'agent_tap_to_close'` | Agent widget collapsed | | `SdkEventName.genToolTriggered` | `'gen_tool_triggered'` | Agent uses an AI tool | | `SdkEventName.rageClick` | `'rage_click'` | Repeated rapid taps on one element | | `SdkEventName.screenView` | `'screen_view'` | Screen / route changed | | `SdkEventName.microphonePermissionAllowed` | `'microphone_permission_allowed'` | Mic permission granted | | `SdkEventName.microphonePermissionDenied` | `'microphone_permission_denied'` | Mic permission denied | *** ## 10. Utility Functions [#10-utility-functions] | Function | Description | | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `embedInitialize({required apiKey, embedUrl, required appVersion, onResult})` | Initialize the SDK before `runApp` — the ONLY place credentials / app version enter | | `embedEvent(EventKeys, EventPayload)` | Send a typed event to the agent | | `embedTrackEvent(String key, Map payload)` | Send an analytics event under a raw string key | | `embedClearStorageCache()` | Wipe all persisted SDK data — call on logout | | `embedOnAgent(listener)` / `embedOnAgent(String event, listener)` → `String` | Subscribe to all agent lifecycle events, or just one; returns a handle | | `embedOffAgent(handle)` / `embedOffAgent(event, handle)` | Unsubscribe using the handle from `embedOnAgent` | | `embedOn(String key, listener)` → `String` / `embedOff(key, handle)` | Subscribe to any raw SDK event key | | `embedTrackRageClick(elementId, x, y)` | Report a rage-click from host code | | `embedTrackFormEvent(String formId, Map fields)` | Report a form interaction from host code | | `EmbedWidget.isActive` | `true` when the agent FAB is visible on the current route (config loaded, route enabled, not hidden) | | `EmbedWidget.isLive` | `true` once the SDK's widget config has loaded from the server (the agent is ready) | | `EmbedWidget.forceCleanup()` | Force-stop the agent and release resources | ### Call control [#call-control] | Function | Description | | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `embedStartCall()` / `EmbedWidget.startCall({agentTriggerMode})` | Start a voice call. Omit the mode ⇒ **co-pilot**; pass `AgentTriggerMode.WORKFLOW` for the Revrag-owned panel/avatar surface. | | `embedEndCall()` / `EmbedWidget.endCall()` | End the in-progress call. | | `embedIsCallActive()` / `EmbedWidget.isCallActive` | Whether a call is live. | | `EmbedWidget.openAvatarScreen()` | Open the full-screen avatar surface (starts a workflow call if none is running, else re-opens it). | | `embedCollapseWidget()` / `EmbedWidget.collapseWidget()` | Fold the expanded bar back to the FAB (the call keeps running). | | `embedExpandWidget()` / `EmbedWidget.expandWidget()` | Expand the FAB to the bar, as if the user tapped it (emits `agent_tap_to_open`). `startCall()` does **not** auto-expand — pair it with this to surface the in-call bar. | | `EmbedWidget.clearUserSession()` | Drop per-user state (ends any call) — call on logout. | **`AgentTriggerMode`** — `CO_PILOT` (default): the host app owns the UI, the agent operates your own screens, and agent-driven panels are dropped. `WORKFLOW`: Revrag owns the UI, panels render, and the full-screen avatar opens on connect. The mode is declared at `startCall` and latched for the life of the call. ```dart // co-pilot (default) — the agent drives your app's own screens await EmbedWidget.startCall(); // workflow — the Revrag-owned panel / avatar surface await EmbedWidget.startCall(agentTriggerMode: AgentTriggerMode.WORKFLOW); ``` **Clear cache on logout:** ```dart Future onLogout() async { await embedClearStorageCache(); // Navigate to login screen } ``` *** ## 11. Complete End-to-End Example [#11-complete-end-to-end-example] This example covers everything: GoRouter with a bottom tab shell, `EmbedRouteListener` for tabs, `USER_DATA` after login, `SCREEN_STATE` for sub-screen context, and `CUSTOM_EVENT` / `ANALYTICS_DATA` for interactions. ```dart // pubspec.yaml dependencies: // flutter: sdk: flutter // embed_flutter: ^0.3.0 // go_router: ^14.0.0 import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:embed_flutter/embed_flutter.dart'; // ════════════════════════════════════════════════════════════════════════════════ // main.dart // ════════════════════════════════════════════════════════════════════════════════ void main() { embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0'); runApp(const MyApp()); } // ════════════════════════════════════════════════════════════════════════════════ // Router // ════════════════════════════════════════════════════════════════════════════════ final _router = GoRouter( initialLocation: '/login', observers: [EmbedNavigatorObserver()], routes: [ GoRoute( path: '/login', name: 'login', builder: (_, __) => const LoginScreen(), ), // Persistent tab shell ShellRoute( builder: (_, __, child) => MainShell(child: child), routes: [ GoRoute( path: '/home', name: 'home', builder: (_, __) => const HomeTab(), ), GoRoute( path: '/plans', name: 'plans', builder: (_, __) => const PlansTab(), routes: [ GoRoute( path: 'detail/:id', name: 'plan_detail', builder: (_, s) => PlanDetailScreen(id: s.pathParameters['id']!), ), ], ), GoRoute( path: '/profile', name: 'profile', builder: (_, __) => const ProfileTab(), ), ], ), // Full-screen checkout screens outside the tab shell GoRoute( path: '/cart', name: 'cart', builder: (_, __) => const CartScreen(), ), GoRoute( path: '/payment', name: 'payment', builder: (_, __) => const PaymentScreen(), ), GoRoute( path: '/confirmation', name: 'confirmation', builder: (_, __) => const ConfirmationScreen(), ), ], ); // ════════════════════════════════════════════════════════════════════════════════ // Root app widget // ════════════════════════════════════════════════════════════════════════════════ class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return EmbedWidget( showEmbedWidget: true, // Agent appears on these screens; hidden on login and profile enabledRoutes: const ['home', 'plans', 'plan_detail', 'cart', 'payment', 'confirmation'], onPermissionStatusChanged: (granted) { if (!granted) debugPrint('Mic permission denied'); }, child: MaterialApp.router( routerConfig: _router, theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue), ), ); } } // ════════════════════════════════════════════════════════════════════════════════ // Shell scaffold // ════════════════════════════════════════════════════════════════════════════════ class MainShell extends StatelessWidget { final Widget child; const MainShell({super.key, required this.child}); @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.toString(); int idx = 0; if (location.startsWith('/plans')) idx = 1; if (location.startsWith('/profile')) idx = 2; return Scaffold( body: child, bottomNavigationBar: NavigationBar( selectedIndex: idx, onDestinationSelected: (i) { switch (i) { case 0: context.go('/home'); break; case 1: context.go('/plans'); break; case 2: context.go('/profile'); break; } }, destinations: const [ NavigationDestination(icon: Icon(Icons.home), label: 'Home'), NavigationDestination(icon: Icon(Icons.description), label: 'Plans'), NavigationDestination(icon: Icon(Icons.person), label: 'Profile'), ], ), ); } } // ════════════════════════════════════════════════════════════════════════════════ // Login screen — identify the user after sign-in // ════════════════════════════════════════════════════════════════════════════════ class LoginScreen extends StatelessWidget { const LoginScreen({super.key}); void _login(BuildContext context) { // Your auth logic here ... // Identify the user to the SDK after successful authentication embedEvent( EventKeys.USER_DATA, UserEventPayload( app_user_id: 'user_42', data: { 'name': 'Rahul Verma', 'email': 'rahul@example.com', 'plan': 'free', }, ), ); context.go('/home'); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Login')), body: Center( child: ElevatedButton( onPressed: () => _login(context), child: const Text('Sign In'), ), ), ); } } // ════════════════════════════════════════════════════════════════════════════════ // Tab screens — each wrapped with EmbedRouteListener // ════════════════════════════════════════════════════════════════════════════════ class HomeTab extends StatelessWidget { const HomeTab({super.key}); @override Widget build(BuildContext context) { return EmbedRouteListener( routeName: 'home', // ✅ in enabledRoutes — agent visible child: Scaffold( appBar: AppBar(title: const Text('Home')), body: Center( child: ElevatedButton( onPressed: () => context.go('/cart'), child: const Text('Go to Cart'), ), ), ), ); } } class PlansTab extends StatelessWidget { const PlansTab({super.key}); @override Widget build(BuildContext context) { return EmbedRouteListener( routeName: 'plans', // ✅ in enabledRoutes — agent visible child: Scaffold( appBar: AppBar(title: const Text('Plans')), body: ListView( children: [ ListTile( title: const Text('Gold Plan — ₹999/mo'), onTap: () { // Send context so the agent knows which plan is being viewed embedEvent( EventKeys.SCREEN_STATE, ScreenEventPayload( screen: 'plan_detail', data: {'plan_id': 'gold', 'price': 999}, ), ); context.pushNamed('plan_detail', pathParameters: {'id': 'gold'}); }, ), ListTile( title: const Text('Platinum Plan — ₹1999/mo'), onTap: () { embedEvent( EventKeys.SCREEN_STATE, ScreenEventPayload( screen: 'plan_detail', data: {'plan_id': 'platinum', 'price': 1999}, ), ); context.pushNamed('plan_detail', pathParameters: {'id': 'platinum'}); }, ), ], ), ), ); } } class ProfileTab extends StatelessWidget { const ProfileTab({super.key}); @override Widget build(BuildContext context) { return EmbedRouteListener( routeName: 'profile', // not in enabledRoutes — agent hidden child: Scaffold( appBar: AppBar(title: const Text('Profile')), body: const Center(child: Text('Your profile')), ), ); } } // ════════════════════════════════════════════════════════════════════════════════ // Plan detail screen // ════════════════════════════════════════════════════════════════════════════════ class PlanDetailScreen extends StatelessWidget { final String id; const PlanDetailScreen({super.key, required this.id}); @override Widget build(BuildContext context) { // plan_detail is a top-level GoRoute so EmbedNavigatorObserver catches the push return Scaffold( appBar: AppBar(title: Text('Plan: $id')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Plan ID: $id', style: const TextStyle(fontSize: 18)), const SizedBox(height: 24), ElevatedButton( onPressed: () { // Inform the agent the user selected a plan embedEvent( EventKeys.CUSTOM_EVENT, CustomEventPayload( data: {'action': 'plan_selected', 'plan_id': id}, ), ); context.go('/cart'); }, child: const Text('Add to Cart'), ), ], ), ), ); } } // ════════════════════════════════════════════════════════════════════════════════ // Checkout screens // ════════════════════════════════════════════════════════════════════════════════ class CartScreen extends StatelessWidget { const CartScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Cart')), body: Center( child: ElevatedButton( onPressed: () => context.go('/payment'), child: const Text('Proceed to Payment'), ), ), ); } } class PaymentScreen extends StatelessWidget { const PaymentScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Payment')), body: Center( child: ElevatedButton( onPressed: () { // Track the conversion event embedEvent( EventKeys.ANALYTICS_DATA, AnalyticsDataEventPayload( event_name: 'payment_completed', data: {'amount': 999, 'currency': 'INR', 'method': 'upi'}, ), ); context.go('/confirmation'); }, child: const Text('Pay ₹999'), ), ), ); } } class ConfirmationScreen extends StatelessWidget { const ConfirmationScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Confirmed!')), body: const Center(child: Text('Your order is placed.')), ); } } ``` *** ## 12. Safety — the SDK cannot crash your app [#12-safety--the-sdk-cannot-crash-your-app] The SDK is a guest in your app and is engineered so that **nothing it does can bring the host down**. You do not need any defensive wrapping around it. * **Public API never throws at your call site.** A mismatched `embedEvent` payload returns `false`; a wrong-signature or bad-argument `embedOnAgent` returns an empty handle; a throwing `nameExtractor` you pass to `EmbedNavigatorObserver` falls back to the route name instead of breaking navigation; `embedClearStorageCache()` never rejects. * **Async work is fully fenced.** Every SDK-owned timer, stream listener, post-frame callback and background task is individually guarded, so nothing the SDK does in the background escapes into your app's error zone. * **Malformed backend config can't crash a build.** Every field from the server `widget_config` is type-coerced (a wrong-typed color, size or flag falls back to a default) rather than cast blindly, so no configuration response can throw during a widget build. * **The LiveKit voice engine is fenced.** A chained `PlatformDispatcher.onError` (installed by `embedInitialize`) swallows only the voice stack's own async exceptions — LiveKit's typed errors (e.g. a renegotiation `NegotiationError`) and any untyped error thrown from inside `livekit_client` / `flutter_webrtc` (such as a signaling race) — and passes **every other error straight through to your existing handler**, so your own crash reporting and behaviour are untouched. The guard is scoped: it suppresses only errors originating in the SDK's voice dependency. Your app's own exceptions reach your handlers exactly as before. *** ## 13. Troubleshooting [#13-troubleshooting] ### Agent button not visible [#agent-button-not-visible] | Check | Fix | | ------------------------------------------- | -------------------------------------------------------------------------------- | | `showEmbedWidget` is `false` | Set it to `true` | | Route name is not listed in `enabledRoutes` | Add it to `enabledRoutes` | | `EmbedNavigatorObserver` is missing | Add it to `navigatorObservers` on `MaterialApp` or `observers` on `GoRouter` | | `USER_DATA` event not sent | The agent requires user identity. Call `embedEvent(USER_DATA, ...)` after login. | | Invalid API key | Verify the key passed to `embedInitialize` | | Network error on startup | Check the `onResult` callback in `embedInitialize` for the error message | ### Agent not showing on a specific tab (ShellRoute / BottomNavigationBar) [#agent-not-showing-on-a-specific-tab-shellroute--bottomnavigationbar] | Check | Fix | | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Tab screen not wrapped with `EmbedRouteListener` | Wrap each tab body with `EmbedRouteListener(routeName: '...')` | | `pushReplacementNamed` not used for tab switches (MaterialApp) | Switch to `pushReplacementNamed` so `didReplace` fires on `EmbedNavigatorObserver` | | Tab route name not in `enabledRoutes` | Add it to `enabledRoutes` | | `routeName` in `EmbedRouteListener` doesn't match `enabledRoutes` | Both must be identical strings | ### iOS microphone permission never requested [#ios-microphone-permission-never-requested] | Check | Fix | | -------------------------------------------------------- | --------------------------------------------------------- | | `NSMicrophoneUsageDescription` missing from `Info.plist` | Add the key with a usage description string | | `PERMISSION_MICROPHONE=1` missing from Podfile | Add the macro inside `post_install` and run `pod install` | ### GoRouter routes not tracked [#gorouter-routes-not-tracked] | Check | Fix | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | Observer missing | Add `EmbedNavigatorObserver()` to `GoRouter.observers` | | Using path strings in `enabledRoutes` instead of route names | Use the `name:` field value of `GoRoute`, not the `path:` string | | `nameExtractor` needed | If GoRoute `name` values differ from what you want to match, supply a custom `nameExtractor` | ### `flutter pub get` can't resolve `embed_flutter ^0.3.0` [#flutter-pub-get-cant-resolve-embed_flutter-030] | Check | Fix | | --------------------------------------------- | --------------------------------------------------------------------- | | Flutter older than 3.29 (`flutter --version`) | Upgrade Flutter to 3.29+, or pin `embed_flutter: 0.2.0` until you can | | `environment: sdk:` lower bound below 3.7 | Raise it to `">=3.7.0 <4.0.0"` | ### Screen names show `unknown_flow`, `…/page_1`, or every tab has the same name [#screen-names-show-unknown_flow-page_1-or-every-tab-has-the-same-name] | Check | Fix | | ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | | A page was pushed without a route name (`…/page_1`) | Pass `settings: RouteSettings(name: '…')` to the route, or set `name:` on the `GoRoute` | | All tabs of an `IndexedStack` / `PageView` share one name | Give each tab root a `ValueKey`, or wrap each tab body in `EmbedRouteListener` | | A custom `nameExtractor` uses `runtimeType` / `toString()` | Return a fixed string instead. These change in release and `--obfuscate` builds | See [Stable Screen Names](#56-stable-screen-names). ### An agent action reports a failure [#an-agent-action-reports-a-failure] From 0.3.0 a step counts as succeeded only when the intended control was acted on and the result was seen. Otherwise it fails with a specific reason. Most reasons point at a fix in your widgets: | `failure_reason` | What it means | Fix | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `target_not_found` | No control matched, even after scrolling | Label the control with `Semantics(label: …)`. Anything TalkBack / VoiceOver can't name is invisible to the agent | | `ambiguous_target` | Two controls match equally well; the SDK won't guess | Give them distinct labels or a `Semantics(identifier: …)` | | `target_mismatch` | The only reachable control names something else (for example a field labelled "PIN" when asked for "Full Name") | Put each field's label in the field's own container, or use `InputDecoration(labelText: …)`; name your routes | | `target_disabled` | The control is disabled (`onPressed: null`, `enabled: false`) | Expected: the control really is disabled | | `verification_unavailable` | The action ran but its effect couldn't be tied to the control | Make the change visible on the control itself: expose `Semantics(selected: …)` / `Semantics(checked: …)` on custom tabs, chips and radios | | `tap_had_no_observable_effect` | Nothing tied to the control changed, even after a second look \~1.5 s later. The tap is never repeated | Make sure the handler fires for accessibility taps: wrap a bare `GestureDetector` in `Semantics(button: true, onTap: …)`. A change only in a counter or badge elsewhere doesn't count | | `check_not_applied` / `select_not_applied` | The checkbox, switch or option isn't shown in its new state | Expose `Semantics(checked: …)` / `Semantics(selected: …)` on the custom control | | `screen_not_capturable` | The screen or an open named dialog is in `disabledRoutes` | Expected for excluded screens | **Icon-only buttons** need a label: `Semantics(label: 'Portfolio', button: true, child: …)`. Without one, 0.3.0 names the control from its image file or icon (`pie chart`), or as `Tab 2 of 4` for an unlabelled tab. That describes the icon, not what it does. ### Clearing SDK data on logout [#clearing-sdk-data-on-logout] ```dart Future onLogout() async { await embedClearStorageCache(); // Navigate to login screen } ``` *** ## Reference [#reference] | | | | ------------------- | ------------------------------------------------------------------------------------------- | | Package | `embed_flutter` | | pub.dev | [https://pub.dev/packages/embed\_flutter](https://pub.dev/packages/embed_flutter) | | Flutter | ≥ 3.29 (reference: current stable) · Dart ≥ 3.7 | | Versions & pitfalls | [Compatibility & Dependency Matrix](/embed/integration/flutter/v0.3.0/flutter-dependencies) | | Release notes | [Flutter SDK release notes](/embed/integration/flutter/flutter-releases) | *** ## Support [#support] * **Docs:** [https://docs.revrag.ai](https://docs.revrag.ai/) * **Email:** [contact@revrag.ai](mailto:contact@revrag.ai) **Last updated:** October 2026 · **Flutter:** 3.29.0+ · **`embed_flutter`:** 0.3.0