# Flutter

> One plugin for iOS and Android that wraps the native SDKs, with one Dart API on both platforms.

Source: https://conciergesdk.com/docs/flutter/

`concierge_sdk` is a Flutter plugin that wraps the native [iOS](https://conciergesdk.com/docs/ios.md) and [Android](https://conciergesdk.com/docs/android.md) SDKs, so the chat is the same native SwiftUI and Jetpack Compose screen on both platforms. Not a webview, and not a Flutter widget tree that imitates one.

- iOS 16.4+ and Android 7 (API 24)+.
- One static Dart API on both platforms. It matches the [React Native package](https://conciergesdk.com/docs/react-native.md) one to one, with streams where React Native has hooks.
- Same features as the native SDKs: cached history, offline outbox, photos, cards, client tools, push.
- `configure` makes no network calls. The device registers the first time the chat opens or a push token arrives.
- History follows the user across devices once you call `identify` with a signed token.
- iOS and Android only. For Flutter web, use the [web widget](https://conciergesdk.com/docs/web.md).

## Install

```sh
flutter pub add concierge_sdk
```

The plugin is [concierge_sdk on pub.dev](https://pub.dev/packages/concierge_sdk), published by the verified publisher conciergesdk.com. You can also add `concierge_sdk: ^0.1.0` under `dependencies` in `pubspec.yaml` and run `flutter pub get`.

Raise the minimum platform versions if your project is below them.

`ios/Podfile`:

```ruby
platform :ios, '16.4'
```

`android/app/build.gradle.kts` (or `build.gradle`):

```kotlin
android {
    compileSdk = 37
    defaultConfig {
        minSdk = 24
    }
}
```

Then build as usual with `flutter run`. On iOS the plugin installs through CocoaPods and ships the prebuilt `Concierge.xcframework` inside the package. It does not support Swift Package Manager yet, so Flutter prints a note that `concierge_sdk` does not support it; the build is not affected. On Android it pulls `com.conciergesdk:concierge` from Maven Central, which new Flutter projects already list as a repository.

The plugin never asks for notification permission. Your app decides when to ask.

## Quick start

```dart
import 'package:concierge_sdk/concierge_sdk.dart';
import 'package:flutter/material.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Concierge.configure(appKey: 'app_YOUR_APP_KEY');
  runApp(const MyApp());
}

class SupportButton extends StatelessWidget {
  const SupportButton({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<int>(
      stream: Concierge.unreadCountStream,
      initialData: Concierge.getUnreadCount(),
      builder: (context, snapshot) {
        final unread = snapshot.data ?? 0;
        return TextButton(
          onPressed: () => Concierge.open(),
          child: Text(unread > 0 ? 'Support ($unread)' : 'Support'),
        );
      },
    );
  }
}
```

`configure(appKey:, baseUrl:, debug:, reviewPromptAfterSupport:, locale:)` is called once at startup, after `WidgetsFlutterBinding.ensureInitialized()`. `baseUrl` defaults to `https://api.conciergesdk.com` and must be `https`; plain `http` works only for localhost and the emulator host addresses, or any host in a debug build. `debug: true` shows the native debug overlay in debug builds (connection state, ids, tools, the last 20 requests). Release builds ignore it. `locale` (a BCP 47 tag such as `ja` or `pt-BR`) overrides the device language; `Concierge.setLocale(tag)` changes it later and `setLocale(null)` follows the device again. See [Localization](https://conciergesdk.com/docs/localization.md).

To try a local API, pass `baseUrl: 'http://10.0.2.2:4000'` on the Android emulator (`http://localhost:4000` on the iOS Simulator):

```dart
import 'dart:io';

Concierge.configure(
  appKey: 'app_YOUR_APP_KEY',
  baseUrl: Platform.isAndroid ? 'http://10.0.2.2:4000' : 'http://localhost:4000',
);
```

Android blocks plain HTTP by default, so allow the emulator host in debug builds only. Flutter projects already have `android/app/src/debug/AndroidManifest.xml`; keep its INTERNET permission and add the `<application>` line:

```xml
<!-- android/app/src/debug/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>
    <application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

<!-- android/app/src/debug/res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
    </domain-config>
</network-security-config>
```

Without it the chat stays offline ("You're offline").

## Entry points

- `Concierge.open()` opens the chat full screen (a sheet on iOS, an activity on Android). `Concierge.dismiss()` closes it.
- `Concierge.isChatVisible()` returns a `Future<bool>`, for example to skip your own banner while the chat is on screen.
- `Concierge.isEnabled()` is `false` while the app is turned off in the dashboard. While off the SDK makes no network calls and `open()` does nothing, so hide your Support button. `Concierge.enabledStream` reports changes.

## Unread count

```dart
StreamBuilder<int>(
  stream: Concierge.unreadCountStream,
  initialData: Concierge.getUnreadCount(),
  builder: (context, snapshot) => Badge(
    isLabelVisible: (snapshot.data ?? 0) > 0,
    label: Text('${snapshot.data ?? 0}'),
    child: const Icon(Icons.support_agent),
  ),
)
```

The stream gives the live count of team and AI replies. Once the chat has been opened on the device, it updates live while the app is in the foreground, even with the chat closed, and refreshes when the app returns to the foreground. `getUnreadCount()` returns the last known value and `refreshUnreadCount()` fetches it now.

## Users

```dart
// After sign in, and on every launch while signed in
Concierge.identify(user.id, name: user.name, email: user.email, token: token);

// On sign out
Concierge.logout();
```

Cross-device history needs `token`: an HS256 JWT your server signs with the app's identity secret, with `exp` at most 30 days out. See [Identity verification](https://conciergesdk.com/docs/identity.md). An identify without a token stays on this device only. A rejected token emits an identity rejected event; chat keeps working on this device.

`logout()` forgets the user on this device, and the next user never sees the previous chat. It does nothing for guests, so never call it at launch for signed-out users.

## Reset and suspend

```dart
// "Erase all data": wipe Concierge on this device.
Concierge.reset();

// A decoy or guest session: Concierge goes invisible and silent.
Concierge.suspend();
Concierge.resume();
Concierge.isSuspended();
```

- `reset()` wipes Concierge on this device back to a fresh install without a network request: the device token in secure storage, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It closes the chat. Tools and handlers stay, and the next open registers a new device.
- While suspended the SDK sends no request and keeps no socket open, the chat closes and won't open, the unread count reads 0 and no events fire. Consent prompts and tool calls wait: a pending call is picked up after `resume()` or expires on the server.
- `resume()` brings the entry points back, refreshes the unread count and sends anything queued. Suspension lasts until the app process ends, so call `suspend()` again on the next launch if needed.

## Context

```dart
Concierge.setMetadata({'plan': 'Pro'});                     // fields next to the conversation
Concierge.setContext({'plan': 'pro', 'screen': 'Paywall'}); // structured context, merged
Concierge.breadcrumb('tapped_restore');                      // last 50 are kept

try {
  await sync();
} catch (error, stackTrace) {
  Concierge.captureError(error, stackTrace);                 // newest error, shown next to the conversation
}
```

Context, breadcrumbs and the last error are batched and ride along with the next message the user sends. Never put names or emails in metadata; use `identify`. `clearContext()` removes every context key the app set.

## Client tools

Register actions the AI agent or your team can ask the app to run. The user is asked first, then your handler runs and its result goes back to whoever asked.

```dart
final sub = Concierge.registerTool(
  'resync_library',
  'Resync the music library from the server',
  {
    'type': 'object',
    'properties': {
      'full': {'type': 'boolean'},
    },
  },
  (args) async {
    final tracks = await library.resync(full: args['full'] == true);
    return {'tracks': tracks}; // any JSON, up to 8KB
  },
  title: 'Resync your library?',
);
// later: sub.remove(), or Concierge.unregisterTool('resync_library')
```

- Up to 32 tools. `schema` is a JSON Schema object for the arguments.
- The consent sheet text always comes from your registration, never from the server. Pass `requiresConsent: false` for harmless actions.
- The handler has 30 seconds. Throw to report a failure.
- The native SDK shows the consent sheet; your Dart handler only runs the action.

## Screen links

Register the screens support answers may link to, once at launch. See [Knowledge autopilot](https://conciergesdk.com/docs/knowledge.md).

```dart
Concierge.registerScreen('settings.backup', 'Backup settings', 'myapp://settings/backup');
Concierge.unregisterScreen('settings.backup'); // its buttons stop showing
```

Tapping the button closes the chat and the native SDK opens the deep link. To route in app instead (for example with go_router), set a handler:

```dart
Concierge.onOpenScreen((id, deepLink) {
  router.push(Uri.parse(deepLink).path);
});
```

The deep link never leaves the device: Concierge learns only the id and title, and only while the [knowledge autopilot](https://conciergesdk.com/docs/knowledge.md) is on. When an AI answer is about a registered screen, it can end with an **open screen** button; the SDK shows it only if that id is registered on this device. Ids are 1 to 64 letters, digits, `.`, `_` or `-`, starting with a letter; titles are 1 to 120 characters; up to 100 screens. Registering an id again replaces it, and invalid input is logged and ignored.

## Push notifications

Concierge sends APNs pushes on iOS and FCM pushes on Android. Forward the device token (the raw APNs token on iOS, the FCM token on Android) and route taps. With `firebase_messaging`:

```dart
import 'dart:io';

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

Future<void> setUpPush() async {
  final messaging = FirebaseMessaging.instance;

  // After your app has asked for permission:
  final token = Platform.isIOS ? await messaging.getAPNSToken() : await messaging.getToken();
  if (token != null) Concierge.registerPushToken(token);
  messaging.onTokenRefresh.listen((t) {
    if (Platform.isAndroid) Concierge.registerPushToken(t);
  });

  // In the foreground: keep the badge current without opening the chat.
  FirebaseMessaging.onMessage.listen((m) => Concierge.handleNotification(m.data, openChat: false));

  // Tapped while the app was in the background or closed: open the chat.
  FirebaseMessaging.onMessageOpenedApp.listen((m) => Concierge.handleNotification(m.data));
  final initial = await messaging.getInitialMessage();
  if (initial != null) Concierge.handleNotification(initial.data);
}
```

`handleNotification` returns `true` when the push came from Concierge, updates the badge and, by default, opens the chat. `isConciergeNotification(data)` checks a payload without side effects. Any other push plugin works the same way as long as it gives you the APNs token on iOS and the payload of a tapped notification. Server setup is in [Push notifications](https://conciergesdk.com/docs/push.md).

## Cards and events

Your team or the AI can attach a card to a message: `review` opens your store review page, `paywall` calls `onPaywallCard`, and `offer` is an [Offer Engine](https://conciergesdk.com/docs/offers.md) offer that the SDK redeems on the server for this device.

```dart
Concierge.onPaywallCard((paywallId) {
  router.push('/paywall/$paywallId');
});

// Optional: run offer purchases yourself (RevenueCat, Superwall, your own billing)
Concierge.onOfferCard((redemption) async {
  await Purchases.setAttributes({'concierge_card': redemption.accountToken});
  switch (redemption.mechanism) {
    case ProductMechanism(:final productId):
      await buy(productId);
    case PromotionalMechanism(:final productId, :final offerId, :final signature):
      await buyPromotional(productId, offerId, signature);
    case WinBackMechanism(:final productId, :final offerId):
      await buyWinBack(productId, offerId);
    case AndroidOfferMechanism(:final productId, :final basePlanId, :final offerId):
      await buyPlayOffer(productId, basePlanId, offerId);
    case OfferCodeMechanism(:final redeemUrl):
      await launchUrl(Uri.parse(redeemUrl));
    case PaywallMechanism(:final paywallId):
      router.push('/paywall/$paywallId');
    case CompMechanism():
      break; // granted on the server, a confirmation message follows in the chat
    case UnknownMechanism():
      break; // a kind this plugin version does not know
  }
});
```

`redemption.mechanism` is a sealed `ConciergeOfferMechanism`, so Dart checks that the switch handles every kind. Bind the purchase to the card with `redemption.accountToken` so the sale is attributed to the conversation. Without `onOfferCard`, the SDK runs the default: StoreKit 2 for store offers on iOS, the redeem link for offer codes, `onPaywallCard` for paywalls, and "Unlocking..." for comps. Android has no billing dependency, so store offers there need `onOfferCard`.

```dart
final sub = Concierge.events.listen((event) {
  switch (event) {
    case ChatOpenedEvent():
      analytics.log('support_opened');
    case ChatClosedEvent():
      analytics.log('support_closed');
    default:
      break;
  }
});
// later: sub.cancel();
```

`Concierge.events` is a broadcast stream of sealed `ConciergeEvent` subclasses, one per event the native SDKs emit, for example `ChatOpenedEvent`, `ChatClosedEvent`, `UnreadCountChangedEvent`, `OfferRedeemedEvent` and `AvailabilityChangedEvent`. `Concierge.addEventListener` does the same with a callback and returns a subscription with `remove()`.

## Link purchases to customers

The refund desk, receipts and renewal heads ups only know a purchase is this customer's when the store says so. The simplest route is RevenueCat with the app user id set to the same id you sign into the identity token (`Purchases.logIn(user.id)`). Otherwise pass the customer's account token on your own purchases (it is derived from the verified user id, see [Offers](https://conciergesdk.com/docs/offers.md#link-purchases-to-customers)). With the `crypto` package:

```dart
import 'dart:convert';

import 'package:crypto/crypto.dart';

final _uuid = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false);

String conciergeAccountToken(String userId) {
  if (_uuid.hasMatch(userId)) return userId.toLowerCase();
  final b = md5.convert(utf8.encode(userId)).bytes.toList(); // MD5 of the UTF-8 bytes
  b[6] = (b[6] & 0x0f) | 0x30;
  b[8] = (b[8] & 0x3f) | 0x80;
  final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
  return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-${h.substring(16, 20)}-${h.substring(20)}';
}

// in_app_purchase sends applicationUserName as the app account token on iOS
// and as the obfuscated account id on Google Play.
final param = PurchaseParam(productDetails: product, applicationUserName: conciergeAccountToken(user.id));
await InAppPurchase.instance.buyNonConsumable(purchaseParam: param);
```

Offer card purchases keep using `redemption.accountToken`.

## Wire your paywall, offers and reviews

Your app already has a paywall, products and maybe offers and a review prompt. Concierge cards should open those, not a new screen. The [setup prompt](https://conciergesdk.com/docs/agents.md) does this for you: it finds what the app has (RevenueCat, including RevenueCat Paywalls, Superwall, Adapty, Qonversion, `in_app_purchase` or a custom paywall), wires the handlers below and prefills your account. By hand:

- **Paywall cards** call `onPaywallCard(paywallId)`. Map the id to your existing paywall, placement or offering, and open the main paywall for any id you don't recognize: the dashboard sends `default` unless you type another id.
- **Offer cards** call `onOfferCard(redemption)` after the server redeems them. Run your existing purchase flow for `redemption.mechanism` and pass `redemption.accountToken` to the store so the sale is attributed to the card.
- **Review cards** open the store review page by themselves, but only when the app's store listing is set: the App Store id and Google Play package under **Settings, General** in the dashboard (or `update_app_settings` on the [MCP server](https://conciergesdk.com/docs/mcp.md)).
- **Review prompts:** replace your own native review request with `maybeRequestReview` at the same moment, so the two don't compete for the system's yearly limit.
- **After a purchase** in your own flow, set the plan in the context and ask for a review at that moment.
- **Purchase identity:** see [Link purchases to customers](#link-purchases-to-customers).

```dart
Concierge.onPaywallCard((paywallId) => router.push('/paywall', extra: paywallId)); // unknown ids open the main paywall

Concierge.onOfferCard((r) async {
  switch (r.mechanism) {
    case PaywallMechanism(:final paywallId):
      router.push('/paywall', extra: paywallId);
    case OfferCodeMechanism(:final redeemUrl):
      await launchUrl(Uri.parse(redeemUrl));
    case CompMechanism():
      break; // granted on the server
    case ProductMechanism() || PromotionalMechanism() || WinBackMechanism() || AndroidOfferMechanism():
      await purchase(r.mechanism, r.accountToken); // your purchase code
    case UnknownMechanism():
      router.push('/paywall');
  }
});

// After a purchase in your own flow
Concierge.setContext({'plan': productId});
await Concierge.maybeRequestReview('purchase_completed');
```

With RevenueCat, call `Purchases.setAttributes({'concierge_card': r.accountToken})` before buying and present paywalls with `RevenueCatUI.presentPaywall`. With `in_app_purchase`, pass `applicationUserName: r.accountToken` in the `PurchaseParam`. Replace `InAppReview.instance.requestReview()` with `Concierge.maybeRequestReview`.

The SDK reports which handlers are set (SDK 0.1.1 or later), so the dashboard's Connect page, the `verify_install` MCP tool and `npx @conciergesdk/cli doctor` show whether paywall and offer cards are wired and whether review cards have a link. A card with no handler logs a warning and emits ``PaywallUnhandledEvent` or `OfferUnhandledEvent``.

## Appearance

Dashboard settings apply without an app update. Local overrides win:

```dart
Concierge.setAppearance(ConciergeAppearance(
  accentLight: '#4F46E5',
  accentDark: '#A5B4FC',
  title: 'Help',
  welcome: 'Hi! How can we help?',
  colorScheme: ConciergeColorScheme.system,
));
```

`showsCloseButton` is iOS only and `colorScheme` is Android only. See [Appearance](https://conciergesdk.com/docs/appearance.md).

## Reviews, cancel flow and paywall help

```dart
final result = await Concierge.maybeRequestReview('milestone');
// result.shown, result.reason

Concierge.presentCancelFlow(productId: 'pro_yearly');

final session = await Concierge.paywallHelp(PaywallContext(/* the plans and prices your paywall shows */));
```

- `maybeRequestReview(moment)` asks the system review API only when the native SDK's local rules and the server agree, the same rules as on [iOS](https://conciergesdk.com/docs/ios.md) and [Android](https://conciergesdk.com/docs/android.md). Moments are `milestone`, `purchase_completed`, `content_created` or your own name. `shown` means the system API was called; `reason` says why not otherwise. `reviewPromptAfterSupport: false` in `configure` turns off the automatic ask after a chat with praise.
- `presentCancelFlow()` shows the in-app cancel flow from your own "Cancel subscription" button: why are you leaving, then the best retention step for that reason. "Continue to cancel" is on every screen and goes to the store's subscription page, so cancelling is never blocked.
- `paywallHelp()` is for a "Questions?" button on your paywall. It sends what the paywall shows so answers fit it, then opens the chat, and returns a `PaywallHelpSession` (pass its account token to the purchase) or `null` when it could not start.
- `getAttestationStatus()` reports the device attestation state the native SDK has.
- `getRefundConsentText()` and `setRefundDataConsent(granted, text)` record the user's choice about sharing purchase info with Apple for refund requests. Call it only from an explicit user action, such as a toggle in your settings.

## How it behaves

- **Native on both platforms:** the plugin links the prebuilt native SDKs (`Concierge.xcframework` on iOS, shipped inside the plugin, and the `com.conciergesdk:concierge` AAR from Maven Central on Android), so the chat, offline outbox, cached history and photo handling are exactly the native SDKs'.
- **Same protocol everywhere:** one inbox covers iOS, Android, React Native, Flutter and the web widget, and history follows a verified user across all of them.
- **Realtime only while needed:** the WebSocket opens while the chat is visible, and in the foreground once the chat has been opened (for live unread and tool calls). It closes in the background.
