pilotpm_engage (Dart/Flutter)

SDK for PilotPM Engage: events (identify/track/screen), push-token registration, in-app messages, and the Genie voice companion (a floating mic button — see below). Talks to /api/sdk/v1/* with a per-workspace write-only ingest key. Full host-app wiring (dual-write beside Braze, push, in-app render widget): docs/engage-sdk/ELSA-FLUTTER-INTEGRATION.md.

Why (almost) pure Dart

The events/push/in-app core has no platform-channel code, so it drops into a Flutter app as just another analytics provider — ideal for dual-writing alongside Braze during a migration. The one exception is Genie voice, which needs a microphone and a speaker: those live behind the GenieAudioIO interface, with record + just_audio as the default implementation.

Usage

import 'package:pilotpm_engage/pilotpm_engage.dart';

// At startup (awaits identity + queue hydration):
await PilotPMEngage.instance.configure(
  PilotPMConfiguration(apiKey: 'pk_live_…'),
);

// On login:
PilotPMEngage.instance.identify('user-42', traits: {'plan': 'pro'});

// Anywhere (synchronous, fire-and-forget, fail-soft):
PilotPMEngage.instance.track('Lesson Completed', properties: {'score': 9});
PilotPMEngage.instance.screen('Home');
PilotPMEngage.instance.setUserAttributes({'streak_days': 12});

// On logout:
await PilotPMEngage.instance.reset();

Push notifications

The SDK stays pure-Dart — keep your existing FCM/APNs setup and just hand PilotPM the token you already have:

// when FCM/APNs gives you a token (and on every refresh):
await PilotPMEngage.instance.registerPushToken(token, platform: 'ios'); // or 'android'
// on logout / OS revocation:
await PilotPMEngage.instance.unregisterPushToken(token, platform: 'ios');

OS push-permission attribute (segmentable)

Report the OS notification-permission state so marketing can segment on it (profile field os_push_permission; e.g. a "push disabled" in-app campaign). This is the OS permission, not the marketing subscription state. With firebase_messaging (the ELSA wiring):

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:pilotpm_engage/pilotpm_engage.dart';

PilotPMPushPermission _mapPermission(AuthorizationStatus s) {
  switch (s) {
    case AuthorizationStatus.authorized:
      return PilotPMPushPermission.authorized;
    case AuthorizationStatus.denied:
      return PilotPMPushPermission.denied;
    case AuthorizationStatus.provisional:
      return PilotPMPushPermission.provisional;
    case AuthorizationStatus.notDetermined:
      return PilotPMPushPermission.notDetermined;
  }
}

// On login — attach it to the identify:
final settings = await FirebaseMessaging.instance.getNotificationSettings();
PilotPMEngage.instance.identify(
  userId,
  attributes: PilotPMUserAttributes(
    pushPermission: _mapPermission(settings.authorizationStatus),
  ),
);

// On change (after the permission prompt / returning from Settings) — re-send:
PilotPMEngage.instance.setPushPermission(
  _mapPermission(settings.authorizationStatus),
);

Applied server-side for identified users only; last write (by event time) wins.

In-app messages

Fetch eligible messages (the server applies targeting + impression caps + dismissal suppression) and report interactions:

final messages = await PilotPMEngage.instance
    .fetchInAppMessages(trigger: 'app_open', locale: 'vi'); // locale optional
for (final m in messages) {
  // render m.title / m.body / m.imageUrl / m.ctaLabel, then:
  await PilotPMEngage.instance
      .reportInAppEvent(m.id, PilotPMInAppEventType.impression);
  // on CTA tap: reportInAppEvent(m.id, PilotPMInAppEventType.click) + route m.ctaDeeplink
  // on close:   reportInAppEvent(m.id, PilotPMInAppEventType.dismiss)
}

A copy-paste reference render widget is in docs/engage-sdk/ELSA-FLUTTER-INTEGRATION.md.

Genie voice

A floating microphone the learner taps and speaks to. Voice only — no transcript, no text UI, no greeting on app open. Everything else (whether the button shows, what Genie knows, how it sounds, which languages) is controlled from the PilotPM workspace, so after this one integration there is no further app release to ship.

import 'package:pilotpm_engage/pilotpm_engage.dart';
import 'package:pilotpm_engage/pilotpm_engage_genie.dart';

// 1. Configure — the same call you already make for events.
await PilotPMEngage.instance.configure(
  PilotPMConfiguration(apiKey: 'pk_live_…'),
);

// 2. (Optional) Personalize: hand Genie the signed-in learner's ELSA session
//    token. Return null when signed out. Without this, Genie still works —
//    it just cannot answer "how am I doing?".
PilotPMEngage.instance.genieIdentity(() async => session?.token);

// 3. Drop the widget into the tree, above your page (e.g. in a Stack).
Scaffold(
  body: Stack(
    children: [
      HomePage(),
      PilotPMGenieButton(workspaceSlug: 'elsa-speak'),
    ],
  ),
);

The button stays hidden until GET /api/sdk/v1/genie/config reports Genie enabled for the workspace, and re-checks on every app resume — turning Genie off in the workspace hides it in every installed app. It uses the workspace's launcher icon when one is set. Position with alignment: / margin: (default bottom-right).

If your backend already signs identity JWTs (the support-chat integration), pass them instead: PilotPMGenieButton(identityJwtProvider: () async => jwt).

What the host app must declare (the only platform work):

  • iOS Info.plist: NSMicrophoneUsageDescription — e.g. "Talk to Genie, your learning companion."
  • Android AndroidManifest.xml: <uses-permission android:name="android.permission.RECORD_AUDIO" />

Audio is recorded natively (no webview), sent to PilotPM for transcription, and never stored or logged on the device. Any Genie endpoint failure hides the button or returns it to idle — it never throws into the host.

To use your own audio stack, implement GenieAudioIO and pass it as PilotPMGenieButton(audioIO: …); the voice loop (GenieVoiceSession) is pure Dart and can also be driven without the widget via PilotPMEngage.instance.genie!.createVoiceSession(audio: …).

The default store is in-memory (events batch within a session but don't survive an app kill). For durability across restarts, pass a shared_preferences-backed adapter:

class PrefsStore implements PilotPMStore {
  final SharedPreferences prefs;
  PrefsStore(this.prefs);
  @override
  Future<String?> getString(String k) async => prefs.getString(k);
  @override
  Future<void> setString(String k, String? v) async =>
      v == null ? prefs.remove(k) : prefs.setString(k, v);
}

await PilotPMEngage.instance.configure(config, store: PrefsStore(prefs));

Drop-in beside Braze (Elsa migration)

Elsa's AnalyticsService fans out to providers implementing AnalyticsProvider (setup/sendEvent/setUserId/setUserProperties/removeUserProperty). The adapter is ~15 lines:

class PilotPMProvider implements AnalyticsProvider {
  final _sdk = PilotPMEngage.instance;
  @override
  Future<void> setup() => _sdk.configure(PilotPMConfiguration(apiKey: '…'), store: …);
  @override
  void sendEvent(String name, {Map<String, dynamic>? params}) =>
      _sdk.track(name, properties: params);
  @override
  void setUserId(String userId) => _sdk.identify(userId);
  @override
  void setUserProperties(Map<String, dynamic> props) => _sdk.setUserAttributes(props);
  @override
  void removeUserProperty(String key) => _sdk.setUserAttributes({key: null});
}

Register it next to BrazeProvider and dual-write; validate parity on the PilotPM dashboard, then retire Braze.

Develop

cd sdk/flutter
flutter pub get
flutter analyze
flutter test

Libraries

pilotpm_engage
PilotPM Engage in-app SDK for Dart/Flutter.
pilotpm_engage_genie
Genie voice for Flutter — the widget and the plugin-backed audio layer.