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
- Install
- Quick start
- Configuration reference
- Event tracking
- User & device registration
- Attribution capture
- Consent
- Destinations: Firebase, Meta, and other forwarders
- Notification lifecycle tracking
- Notification inbox
- In-app messages
- Offline delivery
- Adopting the SDK in an app with existing users
- Example app
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.
Consent
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
onEventin parallel with the backend call — one broken forwarder never blocks the backend or another forwarder. trackBackendOnlyevents skip forwarders entirely.sdk.setForwarderEnabled('firebase', false)is a runtime kill switch.- Optionally declare intent up front so misconfiguration is caught
early:
If a destination is flaggedOptimizeConfig( destinations: {Destination.firebase: true, Destination.meta: true}, ... )truebut never registered (or itsisReadyisfalse), the SDK logs a clear warning viaonLogon 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
trackPageViewcall 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.