<!-- Source: https://developer.retainza.com/flutter/ · Markdown: https://developer.retainza.com/flutter.md -->

Built, not yet published · pub

# Retainza Flutter SDK

Identify users, track events, register push and receive in-app messages from a Flutter app on iOS and Android.

<!-- section: overview -->

## Overview

`retainza_flutter` is the Dart client for Retainza. It identifies users, tracks events, keeps collections, registers push tokens and receives in-app messages from a Flutter app on iOS and Android.

- **Dart 3.4+** / **Flutter 3.22+**
- **iOS 15+** and **Android 7.0 (API 24)+**

Attribute rules, the offline queue, sessions and consent behave exactly as in the other client SDKs. The [concepts](https://developer.retainza.com/#concepts) section covers them at length; this page covers what is different in Flutter.

> **Warning:** **Not on pub.dev yet.** Version `1.0.0` has not been published, so a plain `retainza_flutter: ^1.0.0` dependency does not resolve today. See [Install](https://developer.retainza.com/flutter/#install) for what works now.

> **Note:** **Status.** Core tracking, push registration and in-app *delivery* are available. The built-in in-app UI (banner, modal, bottom sheet, fullscreen, HTML) is not yet — until it ships, render messages yourself ([In-app messages](https://developer.retainza.com/flutter/#inapp)).

<!-- section: install -->

## 1. Install

Once the package is published, the dependency will be:

```yaml title="pubspec.yaml"
dependencies:
  retainza_flutter: ^1.0.0
```

then `flutter pub get`.

> **Warning:** **Not on pub.dev yet.** `1.0.0` has not been published, so the line above does not resolve outside the Retainza repository today. The source repository is private, so a `git:` dependency does not work for external apps either. Inside the Retainza monorepo, depend on it by path:

```yaml title="pubspec.yaml"
dependencies:
  retainza_flutter:
    path: ../sdk/flutter/retainza_flutter
```

It brings in `http`, `shared_preferences`, `package_info_plus`, `device_info_plus` and `flutter_timezone`. Firebase is deliberately **not** a dependency — an app using Retainza only for analytics should not inherit it. Add it yourself if you use push ([Push](https://developer.retainza.com/flutter/#push)).

The SDK is pure Dart with no platform channels of its own: nothing to link, and no `pod install` steps beyond what the plugins above need.

<!-- section: initialize -->

## 2. Start the SDK

```dart title="lib/main.dart"
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:retainza_flutter/retainza_flutter.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Retainza.init(
    publicKey: const String.fromEnvironment('RETAINZA_PK'), // pk_test_… or pk_live_…
    debug: kDebugMode,
  );
  runApp(const MyApp());
}
```

Then use `Retainza.instance` anywhere. Call `init` once; calling it again replaces the client. `Retainza.instance` throws a `StateError` if `init` has not run.

Only a **public** key (`pk_…`) is accepted — a secret key throws `ArgumentError`. The key decides the environment, read back as `Retainza.instance.environment` (`RetainzaEnvironment.test` for `pk_test_`, `.live` for `pk_live_`, `.unknown` for any other `pk_` key). There is no runtime switch.

> **Warning:** A `pk_live_` key in a debug build (or with `debug: true`) logs a warning: its events and device tokens land in Production. Use a `pk_test_` key for development.

Platform facts (timezone, device, app version, install date) resolve asynchronously. Await them before the very first `identify` if it must carry them — every later call carries them regardless:

```dart
await Retainza.ready();
```

### Configuration worth knowing about

All of these are named parameters of `Retainza.init`:

| Parameter | Default | What it does |
| --- | --- | --- |
| `baseUrl` | `--dart-define=ENGAGE_BASE_URL`, else `https://api.retainza.com/v1` | Ingest API |
| `userProvider` | — | `() => currentUserId`; authoritative when non-null |
| `debug` | `false` | Logs requests and state. Off = no console output at all |
| `logger` | — | Receives lines instead of the console; gets warn/error even with debug off |
| `defaultAttributes` | — | Extra attributes on every identify. App values win |
| `storage` | `SharedPreferencesStorage()` | Queue, install id, session, base-URL override |
| `autoSession` | `true` | Sessions from the app lifecycle ([Sessions](https://developer.retainza.com/flutter/#sessions)) |
| `requireConsent` | `false` | Send nothing until analytics consent ([Consent](https://developer.retainza.com/flutter/#consent)) |
| `allowRuntimeBaseUrl` | value of `debug` | Lets `setBaseUrl` work. Keep off in release |
| `sessionTimeout` | 30 min | Background time before a new session |
| `maxQueuedEvents` | 100 | Offline queue capacity |
| `device` | detected | Override detected `DeviceInfo` fields |
| `httpClient` | — | Override the HTTP client (tests, proxies) |

A dev screen can repoint a debug build without rebuilding:

- `setBaseUrl(url)` — remembers an override; throws `StateError` unless `allowRuntimeBaseUrl` (or `debug`) is on, and the URL must start with `http://` or `https://`.
- `clearBaseUrl()` — forgets the override.
- `getBaseUrl()` — the URL requests actually go to (stored override > config > dart-define > default).
- `testConnection()` — hits `/health` (one level above `/v1`) and returns `(ok, status, error)`.

<!-- section: tracking -->

## 3. Identify & track

```dart
final r = Retainza.instance;

await r.identify(
  'user_42',
  attributes: {'plan': 'pro', 'trial_days_left': 3},
  profile: const RetainzaProfile(firstName: 'Asha', email: 'asha@example.com', locale: 'en-IN'),
);

await r.setAttributes({'plan': 'team'});          // patch — sends only this
await r.track('checkout_completed', properties: {'amount': 499, 'currency': 'INR'});
```

After `identify`, later calls use that user id; pass `userId:` to override, or configure `userProvider`. A call with no resolvable user throws `StateError`.

Attribute values are scalars: `String`, `num`, `bool` or `null`. `null` values in SDK/default layers are dropped rather than clearing server-side values.

`RetainzaProfile` fields become the reserved attributes `first_name`, `last_name`, `email`, `phone`, `gender`, `birthdate`; `email` and `locale` are also sent as top-level columns. Empty strings are ignored.

### Collections

One-to-many data such as challenges, badges or saved searches. The item id is the merge key, so an offline replay never duplicates an item:

```dart
await r.addToCollection('challenges', CollectionItem('lagos_10k', {'status': 'joined'}));
await r.removeFromCollection('challenges', 'lagos_10k');
await r.setCollection('badges', [CollectionItem('early_bird'), CollectionItem('streak_7')]); // [] clears
```

A `CollectionItem` id may not be empty (the constructor throws `ArgumentError`).

### Signed identity

Optional, and in the SDK's unreleased changes after `1.0.0`. With identity verification on for an environment, your server signs each user id (`hex(HMAC-SHA256(identitySecret, userId))`) and returns it with the session. Pass it on every launch:

```dart
await Retainza.instance.identify(user.id, userHash: userHash);
// or, without re-identifying:
Retainza.instance.setUserHash(userHash);
```

The hash is sent with every call for that user id and no other, is kept in memory only, and is cleared by `reset()`. Never put the identity secret in the app. See [Identity](https://developer.retainza.com/#identity).

### Logout

```dart
await Retainza.reset();
```

Unregisters this device's push token from the user being left, then clears the cached user id, the offline queue, session counters and in-app polling. The install id and `install_date` survive — they describe the device. If you use `userProvider`, make it return `null` after logout.

### Attributes the SDK sets for you

`platform`, `app_version`, `sdk_version`, `install_date`, `language`, `country`, `tz` (IANA, e.g. `Asia/Kolkata`), `device_model`, `device_manufacturer`, `os_name`, `os_version`, the session counters ([Sessions](https://developer.retainza.com/flutter/#sessions)) and `push_permission` once you report it. `country` is the locale region, not GPS — no permission prompt is involved.

<!-- section: push -->

## 4. Push

Retainza sends push through **Firebase Cloud Messaging** on both platforms, so register the **FCM token** — on iOS too, not the raw APNs device token.

**Step 1:** Set up Firebase

Set up Firebase for your app (`flutterfire configure`) and add `firebase_core` and `firebase_messaging`. On iOS, enable the Push Notifications capability and the *Remote notifications* background mode, and upload your APNs key in the Firebase console.

**Step 2:** Report permission and register the token

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

Future<void> setUpPush() async {
  final r = Retainza.instance;
  final fm = FirebaseMessaging.instance;

  // The SDK never prompts — asking is your product decision.
  final settings = await fm.requestPermission();
  await r.setPushPermission(switch (settings.authorizationStatus) {
    AuthorizationStatus.authorized => PushPermission.authorized,
    AuthorizationStatus.provisional => PushPermission.provisional,
    AuthorizationStatus.denied => PushPermission.denied,
    AuthorizationStatus.notDetermined => PushPermission.undetermined,
  });

  final token = await fm.getToken();
  if (token != null) await r.registerPush(token, Retainza.pushPlatform);
  fm.onTokenRefresh.listen((t) => r.registerPush(t, Retainza.pushPlatform));
}
```

Call it after `identify`. `registerPush` is safe on every launch: an unchanged token is re-sent at most once a day (`force: true` overrides). It is never queued — a late token may already be stale — so it throws on network errors.

### Opens and deep links

Every Retainza push carries `engage_*` keys in its data block. `parseRetainzaPush` returns `null` for pushes that belong to another SDK:

```dart
void handleOpen(RemoteMessage m) {
  final push = parseRetainzaPush(m.data);
  if (push == null) return; // not ours
  Retainza.instance.ackPush(push.msgId, PushAction.opened).ignore();
  if (push.route != null) Retainza.instance.resolveRoute(push.route!);
}

FirebaseMessaging.onMessageOpenedApp.listen(handleOpen);
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) handleOpen(initial);
```

`resolveRoute` hands the route to the handler you register with `onNavigate` ([In-app messages](https://developer.retainza.com/flutter/#inapp)). Report `PushAction.delivered` from `FirebaseMessaging.onMessage` (foreground) if you want delivery counts — FCM has no delivery receipt of its own.

<!-- section: inapp -->

## 5. In-app messages

> **Note:** The built-in in-app UI has not shipped yet. The SDK delivers messages; your app renders them.

Poll for messages and hand each to your renderer:

```dart
final r = Retainza.instance;

r.onInApp((msg) {
  // msg.template: banner | modal | bottomsheet | fullscreen | html
  // msg.content: the full payload (title, body, buttons, media, style …)
  showMyDialog(title: msg.content['title'] as String?, body: msg.content['body'] as String?);
});

final stop = r.startInAppPolling(); // every 15 s; returns a stop function
```

`shown` is acked automatically when a message is delivered to `onInApp`. Report the rest yourself:

```dart
await r.ackInApp(msg.msgId, InAppAction.clicked);
await r.ackInApp(msg.msgId, InAppAction.dismissed);
```

`fetchPendingInApp()` does a single poll if you prefer to drive timing. The polling interval can be changed with `startInAppPolling(interval: …)`.

### Navigation

```dart
r.onNavigate((route) => navigatorKey.currentState?.pushNamed(route.screen, arguments: route.kv));
```

Push opens and in-app buttons both resolve through this handler. A route is a `screen` name plus a `kv` map of string params.

### Rendering it yourself (selfhandled)

Campaigns built with the *self-handled* template are never drawn by the SDK and never auto-acked. Your handler owns the acks — skipping them empties that campaign's funnel:

```dart
r.onSelfHandledInApp((h) {
  showMyCard(h.message.content, onVisible: h.markShown, onTap: () => h.markClicked(), onClose: h.markDismissed);
});
```

`markClicked([route])` acks `clicked` and then navigates (to the given route, else the message's own). A self-handled message that arrives with no handler registered stays queued and is logged as a warning.

<!-- section: offline -->

## 6. Offline queue

`identify`, `setAttributes`, `track` and the collection calls survive network failures. Network errors and 5xx responses are queued (100 items by default, oldest dropped first), persisted, and flushed in order with their original timestamps — backoff doubles from 1 s up to 5 min. A 4xx is thrown and never queued. Push registration and acks are never queued.

```dart
Retainza.instance.queueSize;          // pending calls
await Retainza.instance.flushQueue(); // drain now
```

<!-- section: consent -->

## 7. Consent

```dart
Retainza.init(publicKey: pk, requireConsent: true);
// … later, from your consent UI:
await Retainza.instance.setConsent(analytics: true, marketing: false);
```

With `requireConsent`, nothing leaves the device until `analytics: true`, and calls made before that are **dropped, not queued**. `marketing` never gates sending; it is stored as `consent_marketing` for targeting.

> **Warning:** `optOut()` stops everything and clears the queue until `optIn()` or the next launch. It is not persisted — re-apply it on each launch from your own stored preference.

<!-- section: sessions -->

## 8. Sessions

With `autoSession` on (default), the SDK starts a session at init and on each return to the foreground, and banks foreground time when the app goes to the background. Returning within 30 minutes (`sessionTimeout`) resumes the same session. A new session emits a `session_start` event and updates `session_count`, `first_session_at`, `last_session_at` and `session_duration_total`.

Turn it off (`autoSession: false`) and call `startSession()` / `endSession()` yourself if you need custom rules.

<!-- section: troubleshooting -->

## 9. Troubleshooting

| Symptom | Check |
| --- | --- |
| Nothing reaches the dashboard | `debug: true` and read the log; `testConnection()`; the key belongs to the environment you are looking at |
| `StateError: no userId` | Call `identify` first, pass `userId:`, or set `userProvider` |
| Events arrive late | Device was offline — they were queued; `queueSize` shows the backlog |
| Nothing sent at all with `requireConsent` | `setConsent(analytics: true)` has not been called |
| Push token registered, no pushes on iOS | You registered the APNs token instead of `FirebaseMessaging.instance.getToken()`, or no APNs key in Firebase |
| Self-handled campaign shows 0 impressions | Your handler never calls `markShown()` |
| First identify lacks device/timezone | `await Retainza.ready()` before it |

The SDK's example app exercises every call. With the SDK source checked out, run it from its `example/` directory:

```bash
flutter run --dart-define=RETAINZA_PK=pk_test_xxx
```

Other platforms: [React Native](https://developer.retainza.com/react-native/), [iOS](https://developer.retainza.com/ios/), [Android](https://developer.retainza.com/android/), [server](https://developer.retainza.com/server/). The HTTP API is in the [reference](https://developer.retainza.com/#api).
