GuideXR Optimize Flutter SDK

The official Flutter SDK for GuideXR Optimize — a patient engagement and revenue intelligence platform for healthcare providers, built by Spatial Guide.

The SDK is a single, versioned client for everything an app needs to talk to GuideXR Optimize — event tracking, user/device registration, attribution capture, notification lifecycle tracking, a notification inbox, and in-app messages — so your app doesn't hand-roll and separately maintain this plumbing.

Contents

Prerequisites

You need a GuideXR Optimize account with an API key and a channel ID. See spatial.guide/guidexr-optimize for account setup.

Install

dependencies:
  guidexr_optimize_flutter_sdk: ^0.1.0

Quick start

Call init once, as early as possible in main():

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

final navigatorKey = GlobalKey<NavigatorState>();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await GuidexrOptimize.init(OptimizeConfig(
    baseUrl: 'https://your-optimize-endpoint',
    apiKey: 'YOUR_API_KEY',
    channelId: 'YOUR_CHANNEL_ID',
    source: 'my_app',
    navigatorKey: navigatorKey,           // required for in-app overlays
    onDeepLink: (link) => router.go(link),
  ));

  runApp(MyApp());
}

Every other API on this page assumes init has already been called and is accessed via the singleton:

final sdk = GuidexrOptimize.instance;

Configuration reference

Everything the SDK needs is one OptimizeConfig, passed once to init.

Field Required Purpose
baseUrl GuideXR Optimize backend endpoint. Resolve your own dev/staging/prod URL before passing it in — the SDK doesn't know about environments.
apiKey Sent as the x-api-key header on every request.
channelId Your channel identifier.
source Default context.source value (e.g. 'my_app'). Overridden automatically once attribution is captured.
enableLogging Verbose diagnostic logging via onLog.
onLog / onError Observability callbacks — pipe into your own logger/crash reporter. The SDK never throws into your app; every internal failure surfaces here instead.
destinations Intent flags per forwarder (see Destinations).
locationProvider Optional. Returns a location snapshot for event/registration payloads. The SDK never bundles location plugins itself — permission handling stays yours.
deviceIdProvider Optional. Returns a stable device id (e.g. android_id / identifierForVendor).
installationIdProvider Optional. Returns an installation id (e.g. Firebase Installations). No provider → sent as "", never fabricated.
appInstanceIdProvider Optional. Firebase Analytics app-instance id, used by the in-app evaluate call.
legacyAnonymousIdProvider Only relevant if you have existing users — see below.
navigatorKey for in-app messages Your app's root navigator key, so the SDK can present in-app overlays.
onDeepLink for in-app messages Called when an in-app overlay tap carries a deeplink. The SDK never navigates itself.
gateDeviceIdUntilIdentified Default false. When true, tracked events omit device_id until registerUser sets a real external_id — see Event tracking.
deferAttributionEventUntilIdentified Default false. When true, attribution_capture is held until the next registerUser call instead of firing immediately with an empty external_id — see Attribution capture.
minRegistrationInterval Default null (disabled). Skips a registerUser/registerDevice call that repeats the same identity/token within this window — see User & device registration.

Event tracking

sdk.trackPageView('home_page');
sdk.trackClick('add_to_cart_click', attributes: {'sku': 'X1'});
sdk.trackBackendOnly('server_only_event');   // skips destination forwarders
sdk.trackCustom('custom_event', attributes: {...});

sdk.trackAppOpen(
  openSource: OptimizeOpenSource.push,       // .push / .deepLink / .direct
  notificationId: id,
  campaignId: campaignId,
);

trackPageView de-duplicates consecutive identical calls automatically — you don't need your own "don't fire the same screen view twice" logic. app_install (fired exactly once per real device) and app_version_changed are handled automatically by init — you never call these yourself.

Attributes that should ride along on every event (not just one call site) — e.g. a field your backend expects on every event, not just in identifiers — can be set once instead of passed everywhere:

sdk.setDefaultEventAttributes({'user_phone': profile.phone ?? ''});
// cleared automatically on reset()

If your backend requires that anonymous (pre-login) traffic carry no persistent device identifier, set gateDeviceIdUntilIdentified: true in your config — device_id is then sent as "" until the first registerUser call sets a real external_id, and blanked again after reset(). Registration calls (registerUser/registerDevice) always send the real device id regardless of this flag — registering is the identify-time call.

User & device registration

await sdk.registerUser(
  externalId: userId,
  profile: OptimizeProfile(
    email: email,
    firstName: firstName,
    lastName: lastName,
    phone: phone,
  ),
  channelPreferences: {'push_opt_in': pushPermissionGranted},
);

await sdk.registerDevice(fcmToken: token, pushEnabled: pushPermissionGranted);

// On logout:
await sdk.reset();

Channel preferences default to opted-in for every channel except push_opt_in, which reflects the OS notification permission and defaults to false if you don't pass it explicitly. An explicit false you do pass is always respected.

If a screen or listener ends up calling registerUser/registerDevice more often than the identity/token actually changes (e.g. on every rebuild, or a notification icon tapped repeatedly), set minRegistrationInterval to skip the redundant network call:

OptimizeConfig(
  minRegistrationInterval: const Duration(minutes: 15),
  ...
)

A call for a genuinely different externalId (registerUser) or a rotated token/toggled push state (registerDevice) is never throttled, regardless of timing — only an exact repeat within the window is skipped. externalId is still recorded locally either way.

Attribution capture

// A deep link (e.g. from your router/deep-link package):
await sdk.captureAttributionFromUri(uri);

// Play Install Referrer:
await sdk.captureAttributionFromInstallReferrer(referrerString);

// A resolved Meta deferred deep link (you fetch it — the SDK has no
// Meta SDK dependency):
await sdk.captureAttributionFromUri(
  resolvedUri,
  attributionSource: 'meta_deferred',
  priority: AttributionSourcePriority.metaDeferred,
);

Capture is priority-based (installReferrer < metaDeferred < manualDeepLink) and expires after 30 days. A single attribution_capture event fires only when the capture actually changes something — not on every call.

Attribution is almost always captured before login (a deep link or install referrer arrives at first launch), so by default attribution_capture fires immediately with an empty external_id. If a downstream workflow correlates that event by identity (e.g. a CRM sync), set deferAttributionEventUntilIdentified: true — the event is held (only the latest capture survives if more than one arrives before login) and fires automatically, with the real external_id, right after your next successful registerUser call.

await sdk.setConsent(OptimizeConsent(
  analytics: true,
  marketing: true,
  location: false,
  version: 'v1',
  source: 'onboarding',
));

Attached automatically to every event and user-registration payload from then on.

Destinations: Firebase, Meta, and other forwarders

The SDK never depends on firebase_analytics or facebook_app_events — apps that don't use them shouldn't have to pull them in. Instead, implement OptimizeForwarder using whatever destinations you've already set up:

class MyFirebaseForwarder implements OptimizeForwarder {
  @override
  bool get isReady => Firebase.apps.isNotEmpty;

  @override
  Future<void> onEvent(OptimizeEvent event) =>
      FirebaseAnalytics.instance.logEvent(
        name: FirebaseNaming.normalizeName(event.name),
        parameters: FirebaseNaming.normalizeParams(event.attributes),
      );

  @override
  Future<void> onIdentify(OptimizeIdentity identity) =>
      FirebaseAnalytics.instance.setUserId(id: identity.externalId);

  @override
  Future<void> onReset() => FirebaseAnalytics.instance.setUserId(id: null);
}

sdk.addForwarder('firebase', MyFirebaseForwarder());

FirebaseNaming.normalizeName/normalizeParams (also exported by the SDK) handle Firebase's own naming rules — lowercase, [a-z0-9_], a 40-character cap, and a 25-parameter cap — so you don't reimplement them.

A few things worth knowing:

  • Every registered, enabled, ready forwarder receives onEvent in parallel with the backend call — one broken forwarder never blocks the backend or another forwarder.
  • trackBackendOnly events skip forwarders entirely.
  • sdk.setForwarderEnabled('firebase', false) is a runtime kill switch.
  • Optionally declare intent up front so misconfiguration is caught early:
    OptimizeConfig(
      destinations: {Destination.firebase: true, Destination.meta: true},
      ...
    )
    
    If a destination is flagged true but never registered (or its isReady is false), the SDK logs a clear warning via onLog on your first tracked event — instead of silently dropping data.
  • Session-replay/screen-tracking tools (e.g. Microsoft Clarity) don't need the full forwarder interface — call their screen-name API directly at your own trackPageView call site instead.

A Meta forwarder, with standard events and identity sync

MetaNaming (also exported by the SDK) maps page views to Meta's own fb_mobile_content_view standard event, so Meta's ad platform recognizes it the way it recognizes that event from its own SDK — a raw custom event name doesn't get that treatment. Meta identity sync (setUserID/setUserData/AdvertiserTrackingEnabled) has no core-SDK hook of its own; it belongs in your forwarder's onIdentify/onReset, same as any other vendor-specific call:

class MyMetaForwarder implements OptimizeForwarder {
  @override
  bool get isReady => true; // Meta App Events has no async init to wait on

  @override
  Future<void> onEvent(OptimizeEvent event) async {
    final standard = MetaNaming.standardEventFor(event.type);
    final params = FirebaseNaming.normalizeParams(event.attributes); // reused for type coercion only
    if (standard == MetaNaming.contentView) {
      await FacebookAppEvents().logEvent(
        name: standard!,
        parameters: {...params, ...MetaNaming.contentViewParams(event.name)},
      );
    } else {
      await FacebookAppEvents().logEvent(name: event.name, parameters: params);
    }
  }

  @override
  Future<void> onIdentify(OptimizeIdentity identity) async {
    await FacebookAppEvents().setUserID(identity.externalId);
    // Sync ATT status yourself (app_tracking_transparency package) —
    // the SDK has no ATT dependency:
    await FacebookAppEvents().setAdvertiserTracking(
      enabled: await isAttAuthorized(),
    );
  }

  @override
  Future<void> onReset() => FacebookAppEvents().clearUserID();
}

Notification lifecycle tracking

sdk.notifications.trackReceived(notificationId);
sdk.notifications.trackImpression(notificationId);
sdk.notifications.trackClicked(notificationId);
sdk.notifications.trackButtonAction(notificationId, buttonId);
sdk.notifications.trackDismissed(notificationId);

Wire these into your own FCM listeners:

FirebaseMessaging.onMessage.listen((message) async {
  if (await sdk.inApp.handleForegroundFcm(message.data)) return; // handled as in-app

  final id = OptimizeNotificationPayload.extractNotificationId(message.data);
  sdk.notifications.trackReceived(id);
  // ... render your own tray notification ...
});

FCM's background isolate doesn't have the GuidexrOptimize singleton — construct a standalone tracker there instead:

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  final id = OptimizeNotificationPayload.extractNotificationId(message.data);
  OptimizeNotificationTracker(yourConfig).trackReceived(id);
}

A repeat call for the same (notificationId, event) within a few seconds is deduped automatically — e.g. FCM's getInitialMessage() and onMessageOpenedApp both firing for one cold-start tap won't double-count a clicked event. You don't need your own guard for that.

Parsing helpers for the data payload:

OptimizeNotificationPayload.extractNotificationId(message.data);
OptimizeNotificationPayload.extractDeeplink(message.data);
OptimizeNotificationButton.parseAll(message.data); // action buttons

Notification inbox

Data layer only — build your own list UI on top:

final page = await sdk.inbox.list(page: 1, pageSize: 20);
await sdk.inbox.setRead(notificationId, isRead: true);
await sdk.inbox.setReadBulk(notificationIds, isRead: true); // auto-chunked at 20
await sdk.inbox.clear(notificationId);
await sdk.inbox.clearAll();

In-app messages

// On launch and on resume:
await sdk.inApp.evaluatePending();

// In your foreground FCM listener (see above) — returns true if the
// payload was an in-app message, whether or not it was displayed:
await sdk.inApp.handleForegroundFcm(message.data);

Given navigatorKey/onDeepLink in your config, matching messages are presented automatically as one of four overlay types (full-screen, modal, top banner, bottom banner), with impression/clicked/ button_action/dismissed tracking built in. You don't build any of this UI yourself. Overlay images are fetched through cached_network_image, so a repeated campaign image isn't re-downloaded on every impression. If the notification arrives before your navigator is mounted (a cold start), the SDK retries showing it a few times over a few seconds before giving up and waiting for the next launch/resume — you don't need your own "not ready yet" handling for that race.

onDeepLink receives whatever string the campaign was configured with — deciding whether that's an in-app route or an external URL is yours to make, the same way you'd handle any other deep link:

onDeepLink: (link) {
  final uri = Uri.tryParse(link);
  if (uri != null && uri.host != 'your-app-domain.com') {
    launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    router.go(link); // internal route
  }
},

Offline delivery

Failed events (transport error or a 5xx response) are queued and retried automatically — you don't need to do anything for this to work. 4xx responses are never retried, since the payload itself was rejected.

await sdk.flushQueue(); // optional manual trigger, e.g. from your own
                        // connectivity listener or app-lifecycle hook

There's no hard guarantee of delivery before the OS suspends your app — that would need native background-task APIs, which this package deliberately doesn't add. flushQueue() narrows the window; it doesn't close it.

Adopting the SDK in an app with existing users

If your app already tracks an anonymous id under its own storage, wire legacyAnonymousIdProvider before shipping to your existing user base — skipping this fires a spurious app_install for every existing user on their first launch of the updated app, corrupting install-count and retention data.

OptimizeConfig(
  ...
  legacyAnonymousIdProvider: () => yourExistingAnonymousIdStorage.peek(),
)

This provider must be strictly read-only. Return null when nothing exists yet; never create a value as a side effect. A "get-or-create" style method (mints a value the first time it's called) will return non-empty even for a genuinely new install, which the SDK cannot distinguish from a real pre-existing value — silently suppressing app_install forever, not just for your existing users. If your existing service only exposes a get-or-create method, add a true read-only variant and wire that instead.

Consulted exactly once — the very first init() call where the SDK's own storage is still empty. Every launch after that reads the SDK's own storage directly and never calls this again.

Example app

See example/ for a runnable app covering initialization, event tracking, and registration.

Libraries

guidexr_optimize_flutter_sdk
Official Flutter SDK for the GuideXR Optimize platform.