Embed Flutter SDK

A powerful Flutter SDK that provides a powerful voice-enabled AI-agent functionality with real-time widget tree monitoring for screen context fetching and realtime voice communication.

Table of Contents

Installation

Add the following dependency to your pubspec.yaml:

dependencies:
  embed_flutter: ^0.4.0

Then run:

flutter pub get

Android Configuration

Nothing to add. The SDK declares the permissions a call needs in its own manifest, and they are merged into your app at build time: INTERNET, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, ACCESS_NETWORK_STATE, WAKE_LOCK, BLUETOOTH_CONNECT, and BLUETOOTH (Android 11 and below). It also asks for them at runtime itself: the microphone when a call starts, and Bluetooth (Android 12+) only when a Bluetooth headset is connected. Listing any of them in your own manifest as well is harmless.

iOS Configuration

1. Add microphone permission to Info.plist

Add the following permissions to your ios/Runner/Info.plist:

<key>NSMicrophoneUsageDescription</key>
<string>This app needs access to microphone for voice calls.</string>

2. Configure Podfile for microphone permission

The SDK requires explicit configuration in your ios/Podfile to enable microphone permission requests. Add the following configuration to your post_install block:

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)',
        ## dart: PermissionGroup.microphone
        'PERMISSION_MICROPHONE=1',
        # ...other permissions...
      ]
    end
  end
end

Important: Without the PERMISSION_MICROPHONE=1 macro in the Podfile, the microphone permission dialog will not appear on iOS and the permission will be reported as permanently denied even if it hasn't been requested before.

Getting Started

Initialization

Before using any EmbedWidget, you must initialize the configuration once in your main.dart:

void main() {
  embedInitialize(
    apiKey: 'your-api-key',              // Required
    appVersion: '1.0.0',                 // Required
    embedUrl: 'https://embed.revrag.ai', // Optional
  );

  runApp(const MyApp());
}

Required: Navigator Observer

For route-based activation to work properly, you must add EmbedNavigatorObserver to your app's navigator observers. This enables the SDK to track route changes and activate/deactivate the EmbedWidget accordingly.

For GoRouter:

import 'package:go_router/go_router.dart';
import 'package:embed_flutter/embed_flutter.dart';

final GoRouter router = GoRouter(
  observers: [EmbedNavigatorObserver()], // Required for route tracking
  routes: [
    // Your routes here
  ],
);

For MaterialApp:

import 'package:embed_flutter/embed_flutter.dart';

MaterialApp(
  navigatorObservers: [EmbedNavigatorObserver()], // Required for route tracking
  // Other MaterialApp properties
)

Required: USER_DATA Event

To activate the EmbedWidget on the screen, you must call the USER_DATA embedEvent with a user ID. This is typically done after user authentication or when you have a user identifier:

import 'package:embed_flutter/embed_flutter.dart';

// Call this after user authentication or when you have a user ID
embedEvent(
  EventKeys.USER_DATA,
  UserEventPayload(
    app_user_id: 'user_12345', // Required: Unique user identifier
    data: {
      'name': 'John Doe', // Optional: Additional user data
      'email': 'john@example.com',
      'phone': '+1234567890',
    },
  ),
);

Important: The EmbedWidget will not be fully functional until the USER_DATA event is sent. This event initializes the voice communication features.

Optional: SCREEN_STATE Event

You can optionally send SCREEN_STATE events to provide additional context about screen changes or user navigation:

// Send screen state when navigating to a new screen
embedEvent(
  EventKeys.SCREEN_STATE,
  ScreenEventPayload(
    screen: 'product_selection_screen', // Required: Screen identifier
    data: {
      'category': 'banking', // Optional: Additional screen context
      'user_type': 'premium',
      'flow_step': 'selection',
    },
  ),
);

Optional: CUSTOM_EVENT

CUSTOM_EVENT lets you capture bespoke analytics or interaction events that matter to your flows (for example, plan selections, button taps, or offer views). Supply any key/value metadata you want to track:

embedEvent(
  EventKeys.CUSTOM_EVENT,
  CustomEventPayload(
    data: {
      'context': 'pricing_screen',
      'event': 'option_selected',
      'option_id': 'plan_12_months',
      'price_per_month': 249,
      'currency': 'INR',
    },
  ),
);

Tip: Combine CUSTOM_EVENT with your flow configuration to build rich analytics around user journeys.

Optional: ANALYTICS_DATA Event

ANALYTICS_DATA is similar to CUSTOM_EVENT but requires an explicit event name. Use this when you need to track structured analytics events with a specific event identifier. This is useful for tracking user actions, conversions, permission status changes or other analytics events that need to be categorized by name:

embedEvent(
  EventKeys.ANALYTICS_DATA,
  AnalyticsDataEventPayload(
    event_name: 'purchase_completed', // Required: Event identifier
    data: {
      'product_id': 'prod_12345',
      'amount': 99.99,
      'currency': 'USD',
      'payment_method': 'credit_card',
    },
  ),
);

Tip: Use ANALYTICS_DATA when you need to track events with a specific name for analytics purposes, while CUSTOM_EVENT is better for general-purpose custom tracking without a named event.

Available Events

Event Purpose Required When to Use
USER_DATA Initialize user context and activate EmbedWidget Required After user authentication or when user ID is available
SCREEN_STATE Provide screen context and navigation info Optional When navigating between screens or when screen context changes
CUSTOM_EVENT Capture bespoke user interactions or analytics Optional Whenever you need additional tracking for specific actions
ANALYTICS_DATA Track structured analytics events with event name Optional When tracking named analytics events (e.g., conversions, purchases, permission status changes)

Basic Usage

Here's a complete example showing how to use the SDK with route-based activation:

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

void main() {
  // 1. Initialize the SDK
  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,
      // Configure route-based activation
      enabledRoutes: const ['home', 'product_selection_screen', 'checkout', 'payment'],
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()], // Required for route tracking
        routes: [
          'home': (context) => const MyHomePage(),
          'product_selection_screen': (context) => const ProductSelectionScreen(),
          'checkout': (context) => const CheckoutScreen(),
          'payment': (context) => const PaymentScreen(),
        ]
        home: MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  void initState() {
    super.initState();

    // 2. Send USER_DATA event to activate EmbedWidget
    // This should typically be called after user authentication
    embedEvent(
      EventKeys.USER_DATA,
      UserEventPayload(
        app_user_id: 'user_12345', // Replace with actual user ID
        data: {
          'name': 'John Doe',
          'email': 'john@example.com',
          'plan': 'premium',
        },
      ),
    );
  }

  void _navigateToProductScreen() {
    // 3. Send SCREEN_STATE event for additional context (Optional)
    embedEvent(
      EventKeys.SCREEN_STATE,
      ScreenEventPayload(
        screen: 'product_selection_screen',
        data: {
          'category': 'banking_products',
          'user_segment': 'new_customer',
        },
      ),
    );

    // Navigate to product screen
    Navigator.pushNamed(context, 'product_selection');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My App')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Welcome to My App!'),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _navigateToProductScreen,
              child: const Text('Go to Product Selection'),
            ),
          ],
        ),
      ),
    );
  }
}

Required Parameters

  • child (required): The main content of your app

Route-Based Activation

The agent button shows only on the routes you list in enabledRoutes. When the user navigates, EmbedNavigatorObserver checks the new route against that list and shows or hides the button — no per-route code needed. (One integration is one agent; there is no per-flow/multi-agent scoping.)

Basic route configuration

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

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      enabledRoutes: const [
        'personal_info',
        'personal_details',
        'product_selection_screen',
        'review_confirm_screen',
        'aadhar_verification_screen',
      ],
      child: MaterialApp.router(
        routerConfig: appRouter,
      ),
    );
  }
}

Route configuration options

  • enabledRoutes: List<String> — route names the agent button may show on.
  • showOnAllRoutes: Show the agent button on all routes (default: false).
  • disabledRoutes: List<String> — route names where the button is always hidden.
  • routeMatchMode: How route names are matched:
    • RouteMatchMode.exact — exact match (default)
    • RouteMatchMode.startsWith — route starts with pattern
    • RouteMatchMode.contains — route contains pattern

Optional Parameters

  • embedUrl (optional): Custom base URL for API endpoints — set via embedInitialize()
  • showEmbedWidget (default: true): Controls whether the embedded widget is visible
  • enabledRoutes (optional): List<String> - route names where the agent button appears
  • showOnAllRoutes (default: false): Show EmbedWidget on all routes
  • disabledRoutes (optional): List of route names where EmbedWidget should be disabled
  • routeMatchMode (default: RouteMatchMode.exact): How to match routes
  • continuity (optional): EmbedButtonContinuity (keepVisible / reset) — whether the FAB stays up or re-shows when navigating between enabled routes
  • embedButtonDelayMs (default: 0): Global pre-show delay (ms) before the FAB appears on an enabled route
  • buttonDelay (optional): EmbedButtonDelay — a delay + EmbedButtonDelayPolicy (perScreen / oncePerGroupEntry / oncePerAppSession)
  • buttonVisibility (optional): EmbedButtonVisibilityConfig — per-screen visibility groups, each with its own continuity / delay / policy (React Native parity)
  • onBackRequested (optional): Future<bool> Function()? — first-chance handler for the agent's "go back"
  • onPermissionStatusChanged (optional): A callback function that receives a boolean indicating whether microphone permission is denied or permanently denied. If the microphone permission is not given, the initial permission request is prompted automatically when the call is started. If the user denies it, the result can be handled accordingly. Called when a call is initiated:
    • true - Microphone permission was granted
    • false - Microphone permission was denied or permanently denied

Embed Widget Position

The agent button's position (and colors) come from the backend widget_config (widget_position / right_padding / bottom_padding), configured from the Revrag dashboard — there are no host position props on EmbedWidget.

Using onPermissionStatusChanged

The onPermissionStatusChanged callback allows you to handle microphone permission status changes when a user attempts to start a voice call. This is useful for showing custom UI feedback or handling permission denial gracefully:

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

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

  // Global key for ScaffoldMessenger to show SnackBars from callbacks
  static final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
      GlobalKey<ScaffoldMessengerState>();

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      // Handle permission status changes
      onPermissionStatusChanged: (bool isGranted) {
        if (isGranted) {
          // Permission granted - call can proceed
          print('Microphone permission granted');
          // You can show a success message or update UI here
        } else {
          // Permission denied - handle accordingly
          print('Microphone permission denied');
          // You can show an error message or guide user to settings
          scaffoldMessengerKey.currentState?.showSnackBar(
            const SnackBar(
              content: Text('Microphone permission is required for voice calls. Please enable it in settings and re-open the app.'),
              duration: Duration(seconds: 3),
            ),
          );
        }
      },
      enabledRoutes: const ['home', 'product_selection_screen'],
      child: MaterialApp(
        scaffoldMessengerKey: scaffoldMessengerKey,
        navigatorObservers: [EmbedNavigatorObserver()],
        home: MyHomePage(),
      ),
    );
  }
}

When is it called?

  • The callback is triggered when a user attempts to start a call
  • It's called after the permission request dialog is shown (if needed)
  • It's called before the call connection is established

Examples

Basic Integration

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

void main() {
  // Initialize the SDK
  embedInitialize(apiKey: 'your-api-key-here', appVersion: '1.0.0');
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()], // Required for route tracking
        home: MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  void initState() {
    super.initState();
    // Send USER_DATA event to activate EmbedWidget
    // This should typically be called after user authentication
    embedEvent(
      EventKeys.USER_DATA,
      UserEventPayload(
        app_user_id: 'user_12345', // Replace with actual user ID
        data: {
          'name': 'John Doe',
          'email': 'john@example.com',
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My App')),
      body: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Welcome to My App!'),
            SizedBox(height: 20),
            Text('The embedded agent is monitoring this screen.'),
          ],
        ),
      ),
    );
  }
}

Form with Monitoring

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

void main() {
  // Initialize the SDK
  embedInitialize(apiKey: 'your-api-key-here', appVersion: '1.0.0');
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()], // Required for route tracking
        home: const MyFormPage(),
      ),
    );
  }
}

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

  @override
  State<MyFormPage> createState() => _MyFormPageState();
}

class _MyFormPageState extends State<MyFormPage> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Send USER_DATA event to activate EmbedWidget
    // This should typically be called after user authentication
    embedEvent(
      EventKeys.USER_DATA,
      UserEventPayload(
        app_user_id: 'user_12345', // Replace with actual user ID
        data: {
          'name': 'John Doe',
          'email': 'john@example.com',
        },
      ),
    );

    // Send SCREEN_STATE event for additional context
    embedEvent(
      EventKeys.SCREEN_STATE,
      ScreenEventPayload(
        screen: 'form_page',
        data: {
          'form_type': 'user_registration',
          'step': 'personal_info',
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My Form')),
      body: Form(
        key: _formKey,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            children: [
              TextFormField(
                controller: _nameController,
                decoration: const InputDecoration(labelText: 'Name'),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your name';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _emailController,
                decoration: const InputDecoration(labelText: 'Email'),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your email';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: () {
                  if (_formKey.currentState!.validate()) {
                    // Form is valid - EmbedWidget will track the submission
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('Form submitted successfully!')),
                    );
                  }
                },
                child: const Text('Submit'),
              ),
            ],
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    super.dispose();
  }
}

Route-Based Configuration

Configure the EmbedWidget with a flat list of routes where the agent button should appear:

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(
      enabledRoutes: const [
        'welcome',
        'personal-info',
        'personal-details',
        'product-selection',
        'payment',
        'review-confirm',
      ],
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()], // Required for route tracking
        initialRoute: '/',
        routes: {
          '/': (context) => const SplashScreen(),
          'welcome': (context) => const WelcomeScreen(),
          'personal-info': (context) => const PersonalInfoScreen(),
          'personal-details': (context) => const PersonalDetailsScreen(),
          'product-selection': (context) => const ProductSelectionScreen(),
        },
      ),
    );
  }
}

Route behavior

  • On an enabled route: the agent button is visible; navigating between enabled routes keeps it up (subject to continuity).
  • Off the enabled routes: the button is hidden and its resources cleaned up; any active call ends.

Custom Router Integration

An Example to show Full integration with Custom Routers we can take example of GoRouter for declarative navigation:

import 'package:go_router/go_router.dart';
import 'package:embed_flutter/embed_flutter.dart';

final appRouter = GoRouter(
  initialLocation: '/splash',
  routes: [
    GoRoute(
      path: '/splash',
      name: 'splash_screen',
      builder: (context, state) => const SplashScreen(),
    ),
    GoRoute(
      path: '/welcome',
      name: 'welcome_screen',
      builder: (context, state) => const WelcomeScreen(),
    ),
    GoRoute(
      path: '/personal-info',
      name: 'personal_info',
      builder: (context, state) => const PersonalInfoScreen(),
    ),
    // ... more routes
  ],
  // Add EmbedNavigatorObserver to the list of observers
  observers: [
    EmbedNavigatorObserver(),
    // ... other observers
  ],
);

void main() {
  WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
  widgetsBinding = EmbedWidgetsBinding();

  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(
      enabledRoutes: const [
        'welcome_screen', 'personal_info', 'product_selection_screen',
        'checkout', 'payment',
      ],
      child: MaterialApp.router(
        routerConfig: appRouter,
      ),
    );
  }
}

Configuration

UI capture and screen scope

During a call, one semantics capture feeds the live agent and the on-device screen cache. The cache receives the capture before live node and byte budgets are applied. Outside a call, the cache captures independently.

The live feed sends a full baseline on connection, screen changes, and request_ui_tree; ordinary updates use diffs (an app foreground/background flush pushes a diff, or a baseline only when the agent has none yet). Every tree frame carries app_user_id (once USER_DATA has been sent), app_version, and sdk {sdk_name, sdk_version, platform} — the same values /ui-graph sync sends. Each serialized LiveKit payload is limited to 15,000 UTF-8 bytes. Snapshots prioritize visible controls and trim to fit, envelope included. Mission exploration audits trim to fit as well (per-node paths first, then older screens, then long text, then the least useful nodes of the current screen) and are marked truncated: true. find_candidates_response sheds its lowest-ranked candidates and info_query_response shortens its value; a response that still cannot be sent goes out as an explicit failure (failure_reason) so the agent never waits for a reply that will not come.

Two questions are kept apart:

  • May the agent look? Missions the agent runs (find, tap, scroll, read a field, verify) are blocked only by the host's explicit exclusions: disabledRoutes (matching the current route or the detected screen name) and an open named popup listed there. enabledRoutes, the platform allowlist and visibility-group routes decide where the button shows and what telemetry is sent, but they do not blind a mission — a mission that navigates to a screen outside the allowlist still sees it on its next step (Android parity: absence from an include list is a default, an exclusion is a decision). action_config.flags.dataCapture and widget visibility do not blind a mission either — data capture off means "no telemetry", not "agent cannot act". To keep the agent away from a screen, list it in disabledRoutes. A response the host refuses is replaced by a failure frame, and a mission phase update is sent without its screen text.
  • May the SDK send telemetry? Live tree pushes (ui_diff) and the mission_exploration audit follow the full scope (disabledRoutes AND the enabled-route list / allowlist) and additionally require dataCapture (default on). While the widget is hidden (e.g. during the per-screen button delay) they continue by default; a tenant can opt out with an explicit action_config.flags.transmitOnHiddenWidget: false (or treePush.transmitOnHiddenWidget: false, Android's spelling). This flag never overrides a route exclusion.

Queued content is checked again against its original route before delivery. The cache may continue on allowed screens while the widget is hidden. Previously persisted files are not deleted by changing route rules.

Keep EmbedNavigatorObserver attached to identify popup routes such as dialogs, bottom sheets, and menus. These remain part of their underlying screen (they never change the page route, FAB visibility, or the page's permissions) and have separate cache records; dismissed overlays retain present: false. A named popup can be excluded like a screen: list its route name (its RouteSettings.name, or what your nameExtractor returns) in disabledRoutes, and nothing is captured or sent — for missions or telemetry — while it is open. Excluding a popup blocks capture only: it does not hide the agent button (the button follows the page route underneath):

showDialog(
  context: context,
  routeSettings: const RouteSettings(name: 'otp_dialog'),
  builder: (_) => const OtpDialog(),
);
// EmbedWidget(disabledRoutes: const ['otp_dialog'], …)

Named popups also emit SCREEN_STATE analytics on open/close. Custom OverlayEntry content without modal semantics and native platform windows are not automatically grouped and cannot be excluded by name — exclude the underlying screen instead.

Stable screen names

Every capture is filed under a screen name, which keys the on-device cache, prefixes element stable IDs, and tells the agent that a tap changed screens. The SDK uses, in order: the current route name (RouteSettings.name, what your nameExtractor returns, or a name set through EmbedRouteListener), then stable signals from the widget tree (a ValueKey<String> on a tab root, Semantics(identifier:), or the page's first short heading), then unknown_flow. Class names are never used, so debug, release and obfuscated builds name every screen identically. Name your routes (and wrap in-page tabs with EmbedRouteListener) for the clearest names — an unnamed page with no stable signal is named after the route below it as route/page_N.

Remote effect and fallback switches

  • action_config.flags.nativeBridge: false turns off the synthetic-pointer tap fallback (the last rung of the tap ladder). Default on; the primary accessibility-action path is never affected.
  • action_config.effects.*Ms values of 0 are accepted but floored at 1 ms, so a tenant asking for "no animation" gets an imperceptible one instead of a crash.

WireLog.enabled prints full wire bodies for debugging and is forced off in release builds.

Icon-only buttons and custom tab bars

The agent finds controls by their accessibility label. For an icon-only tappable with no label or tooltip, the SDK falls back to a name derived from what it draws: the asset file name (assets/nav/ic_home_active.png → "home"), a common Material/Cupertino icon (Icons.pie_chart → "pie chart"), or, for an unnamed bottom-nav item, its position ("Tab 3 of 4"). For a custom bottom bar with no Semantics(selected:), the active item is inferred only when it alone is drawn differently, or from tab taps the session has seen lead to the current screen. For best results, label icon-only nav items and mark the active one yourself:

Semantics(
  label: 'Portfolio',
  selected: currentIndex == 2,
  child: GestureDetector(onTap: () => onTab(2), child: const PortfolioIcon()),
)

Disabled controls (onPressed: null, TextField(enabled: false)) are still found, but a tap or set_text on them fails at once with target_disabled.

Custom Base URL

You can set a custom base URL globally during initialization:

void main() {
  embedInitialize(
    apiKey: 'your-api-key',
    embedUrl: 'https://your-custom-domain.com',
    appVersion: '1.0.0',
  );
  runApp(const MyApp());
}

Troubleshooting

Common Issues

  1. Widget Not Visible

    • Check showEmbedWidget parameter
    • Verify API key is valid
    • Check network connectivity
  2. Voice Call Issues / Microphone Permission Not Being Requested

    • Ensure microphone permission is granted and not permanently denied.

    • Check device audio settings

    • iOS: Verify that NSMicrophoneUsageDescription is added to ios/Runner/Info.plist

    • iOS: Ensure the Podfile includes the permission handler configuration. Add the following to your ios/Podfile if it's missing:

      post_install do |installer|
        installer.pods_project.targets.each do |target|
          target.build_configurations.each do |config|
            config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
            config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'PERMISSION_MICROPHONE=1'
          end
        end
      end
      

      After adding this configuration, run:

      cd ios
      pod install
      cd ..
      
  3. Widget Tree Not Updating

    • Check widget tree handler initialization
    • Verify context is available
    • Check for widget disposal issues
  4. Route-Based Activation Not Working

    • Verify route names match exactly (case-sensitive)
    • Check EmbedNavigatorObserver is added to router observers
    • Ensure GoRouter is properly configured with named routes
    • Verify enabledRoutes contains the correct route names
    • Verify the current route is listed in enabledRoutes
  5. Click Events Missing or Badly Named

    • Clicks are sent only after USER_DATA, while the widget is shown on the route, and while action_config.flags.clickTracking isn't false
    • A control is named by its ValueKey<String>, then Semantics(identifier:), then its tooltip / visible text, then the image / SVG file name or common icon it draws. Give it a ValueKey<String> for a stable name (ValueKey('apply_loan') → apply_loan_pressed)
    • A tap needs semantics: a custom tappable with no onTap semantics (e.g. a raw Listener) isn't reported. Wrap it in Semantics(button: true, onTap: …)
    • See INTEGRATION.md → Click analytics for the full naming rules
  6. EmbedWidget Not Appearing on Expected Screens

    • Check enabledRoutes configuration (a flat list of route names)
    • Check disabledRoutes configuration
    • Verify route matching mode (exact vs startsWith vs contains)
    • Check if showEmbedWidget is set to true
    • Verify the route is listed in enabledRoutes

License

This project is licensed under the Non-Commercial License License - see the LICENSE file for details.

Support

Changelog

See CHANGELOG.md for a complete list of changes and version history.


Libraries

core/cache/cache_manager
core/cache/db/cache_store
core/cache/db/file_cache_store_io
core/cache/db/file_cache_store_web
core/cache/deep_equals
core/cache/embed_cache
core/cache/route_graph
core/cache/screen_merge
core/cache/store_keys
core/cache/sync_engine
core/cache/types
Data model for the on-device UI-graph cache.
core/cache/ui_graph_ack
core/config/action_gates
The remote kill-switch for Action Intelligence.
core/events/click_naming
Click analytics names — a port of Android 1.2.3 ClickNaming.kt (core/events), the wire authority, which RN 1.2.0 ports verbatim too.
core/executors/back_executor
core/executors/check_executor
core/executors/executor_result
core/executors/find_candidates_executor
core/executors/input_executor
core/executors/read_field_executor
core/executors/scroll_executor
core/executors/select_executor
core/executors/slider_executor
core/executors/tap_executor
core/executors/wait_executor
core/explore/explorer
core/explore/label_match
core/explore/scroller_impls
core/overlay/active_border
core/overlay/awakening_overlay
core/overlay/highlighter
core/protocol/event
core/protocol/mission
core/protocol/mission_exploration
core/protocol/result
core/protocol/ui_diff
core/runtime/agent_runtime
core/runtime/cancel_signal
Cooperative cancellation for the mission runtime.
core/runtime/capture_scope
core/runtime/exploration_log
core/runtime/host_touch
core/runtime/mission_dispatcher
core/runtime/mission_runner
core/runtime/outbound_queue
core/runtime/snapshot_pump
core/runtime/voice_loop
core/session/call_modes
core/telemetry/telemetry
core/tree/active_screen_detector
core/tree/capture
core/tree/diff_engine
core/tree/glyph_hints
core/tree/normalize
core/tree/overlay_routes
core/tree/snapshot
core/tree/snapshot_budget
Snapshot budget — cap what one push carries, ranked by what the planner can actually USE.
core/tree/stable_ids
core/tree/tab_memory
core/tree/text_normalizer
core/verify/verifier
debug/debug_console
debug/wire_log
embed_flutter