guidexr_optimize_flutter_sdk 0.2.1
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
- 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.