guidexr_optimize_flutter_sdk 0.2.1 copy "guidexr_optimize_flutter_sdk: ^0.2.1" to clipboard
guidexr_optimize_flutter_sdk: ^0.2.1 copied to clipboard

Official Flutter SDK for the GuideXR Optimize platform — event tracking, user and device registration, and attribution capture.

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.

0
likes
150
points
158
downloads

Documentation

API reference

Publisher

verified publisherspatial.guide

Weekly Downloads

Official Flutter SDK for the GuideXR Optimize platform — event tracking, user and device registration, and attribution capture.

Homepage
Repository (GitHub)

Topics

#analytics #notifications #attribution

License

Apache-2.0 (license)

Dependencies

cached_network_image, device_info_plus, dio, flutter, flutter_secure_storage, package_info_plus, shared_preferences, uuid

More

Packages that depend on guidexr_optimize_flutter_sdk