# Flutter

> Add the Giov chat to a Flutter app as a ready-made screen or on your own controller, with verified identity, attachments, unread counts and push notifications.

The Flutter SDK (`giovchat`, version 0.1) shows the same conversation your website visitors have as a native screen. It matches the React Native SDK and uses the same Giov visitor protocol. File picking, secure storage and push tokens use your app's own packages through small adapters, so you add only the plugins you need.

Colours, greeting, pre-chat fields, GiovBot's name and avatar, attachment rules and branding come from **Settings → Chat widget** in the dashboard. In code, `tenantKey` is your workspace ID, shown in **Settings → Chat widget → Installation**.

## Install

Requires Flutter 3.22 or newer (Dart 3.4).

```bash title="Terminal"
flutter pub add giovchat
```

The package depends only on `http`, `http_parser`, `web_socket_channel` and `url_launcher`. It uses Material icons, so keep `uses-material-design: true` in your `pubspec.yaml` (the default for new apps).

### Platform setup

The chat itself needs network access and nothing else.

- **Android:** release builds need `<uses-permission android:name="android.permission.INTERNET"/>` in `android/app/src/main/AndroidManifest.xml`. Flutter's template declares it only for debug and profile builds.
- **macOS:** add `<key>com.apple.security.network.client</key><true/>` to both `macos/Runner/DebugProfile.entitlements` and `macos/Runner/Release.entitlements`.
- **iOS, web, Windows, Linux:** nothing to add.

Links and attachments open with `url_launcher`, so you don't need `<queries>` or `LSApplicationQueriesSchemes` entries. To open them yourself, pass `onOpenUrl`.

### Web

- Inline images (attachments and teammate avatars) load with `Image.network`. With the CanvasKit and skwasm renderers the browser fetches the bytes itself, so the image host must send CORS headers (`Access-Control-Allow-Origin`). A blocked image shows a placeholder icon (an avatar falls back to the initial); tapping it still opens the file.
- `flutter_secure_storage` on web keeps data in browser storage, which is not a hardware-backed keystore. Leave the default in-memory session store if the conversation doesn't need to survive a reload.
- Push registration is for APNs and FCM device tokens from mobile apps.

## Show the chat

The chat is a widget. Put it in any route, sheet or tab.

```dart title="support.dart"
import 'package:flutter/material.dart';
import 'package:giovchat/giovchat.dart';

void openSupport(BuildContext context) {
  Navigator.of(context).push(MaterialPageRoute<void>(
    builder: (context) => Scaffold(
      body: GiovChat(
        tenantKey: 'YOUR_WORKSPACE_ID',
        onClose: () => Navigator.of(context).pop(), // Shows a close button.
      ),
    ),
  ));
}
```

`GiovChat` creates, starts and disposes its own controller. It draws every state (loading, pre-chat form, greeting, connecting, active conversation, error with retry), passes app lifecycle changes to the controller, and keeps content clear of notches and the keyboard. Inside a bottom sheet that already does that, pass `safeArea: false`.

A conversation starts when the visitor sends their first message or submits the pre-chat form. A stored conversation is resumed first; a closed one is forgotten, and the next message starts a new one.

### Controller or widget

`GiovChat` is a thin screen on top of `GiovChatController`, a `ChangeNotifier` that runs the whole visitor session: phase, messages, header identity, typing, unread count, errors and uploads. Use the widget for the ready-made screen, the controller alone for a custom UI, or both:

```dart title="chat_controller.dart"
final chat = GiovChatController(tenantKey: 'YOUR_WORKSPACE_ID', keepConnected: true, visible: false);
await chat.start();

// Later, anywhere: the ready-made screen on the controller you own.
GiovChat(controller: chat, autoStart: false, onClose: () => Navigator.pop(context));
```

Pass either `controller` or `tenantKey` to `GiovChat`, not both. With a controller you own, the widget never disposes it, so call `chat.dispose()` yourself. When the screen is removed, the widget marks the chat as not visible, so a `keepConnected` controller keeps counting unread replies.

## Signed-in users

Pass the user's details through `contact`. Any field you provide skips the matching pre-chat field.

```dart title="chat.dart"
GiovChat(
  tenantKey: 'YOUR_WORKSPACE_ID',
  contact: GiovContact(
    userId: user.id,
    userHash: user.giovUserHash, // computed on your server
    email: user.email,
    firstName: user.firstName,
  ),
  sessionKey: user.id,
)
```

- `userHash` is the HMAC-SHA256 (lowercase hex) of `userId`, keyed with the identity secret from **Settings → Chat widget → Visitor details**. Compute it on your server and send it to the app; never ship the secret. See [Identify visitors](/docs/widget/identify-visitors) for server examples.
- Without `userHash`, `userId` is recorded as an unverified claim and doesn't link the chat to an existing contact.
- A stored conversation is keyed by the workspace, `sessionKey` and `userId` only. Editing a name, email or phone keeps it. Changing `tenantKey`, `contact.userId` or `sessionKey` on the widget starts a fresh controller.
- **Sign-out and account switching:** first `await chat.unregisterPushToken(token)` if you registered one, then change `sessionKey` or call `reset()`. The previous user's session, queued messages, unread count and remembered push token are dropped, and the next user starts fresh.

With the widget's own controller, reach `reset()` and the controller through a `GlobalKey<GiovChatState>`:

```dart title="sign_out.dart"
final chatKey = GlobalKey<GiovChatState>();

// GiovChat(key: chatKey, tenantKey: 'YOUR_WORKSPACE_ID', ...)

Future<void> signOut(String pushToken) async {
  await chatKey.currentState?.controller.unregisterPushToken(pushToken);
  await chatKey.currentState?.reset();
}
```

## Keep the conversation across launches

By default nothing is stored, and the conversation is forgotten when the app process ends. Pass a `GiovSessionStore` as `sessionStore` to resume the same conversation next time. Implement `read`, `write` and `delete` with secure storage you already have:

```dart title="secure_session_store.dart"
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:giovchat/giovchat.dart';

class SecureSessionStore implements GiovSessionStore {
  const SecureSessionStore([this._storage = const FlutterSecureStorage()]);
  final FlutterSecureStorage _storage;

  // A stable key per scope (hashCode is not stable across runs and may collide).
  String _key(String scope) => 'giovchat.${base64Url.encode(utf8.encode(scope))}';

  @override
  Future<GiovSession?> read(String scope) async {
    final value = await _storage.read(key: _key(scope));
    return value == null ? null : GiovSession.fromJson(jsonDecode(value) as Map<String, Object?>);
  }

  @override
  Future<void> write(String scope, GiovSession session) =>
      _storage.write(key: _key(scope), value: jsonEncode(session.toJson()));

  @override
  Future<void> delete(String scope) => _storage.delete(key: _key(scope));
}

// GiovChat(tenantKey: 'YOUR_WORKSPACE_ID', sessionStore: const SecureSessionStore());
```

> [!WARNING]
> The session token is a private credential. Keep it in the Keychain or Keystore, never in plain `SharedPreferences`, never log it, and never share it between users.

If your store throws, the SDK reports a `GiovStorageException` through `onError` and carries on without the stored session.

## Attachments

Attachments appear once the conversation has started, when you pass an `attachmentPicker` and the published GiovBot agent allows them. Visitors can pick only the allowed types and sizes, up to 5 files per message, and the server checks again.

The SDK ships no picker. `attachmentPicker` is a function that receives a `GiovPickerRequest` (`allowedTypes`, `maxFiles`, `maxSizeMb`) and returns a list of `GiovPickedFile`, or an empty list when the visitor cancels. Create a file with `GiovPickedFile.bytes` (the bytes are in memory) or `GiovPickedFile.stream` (read lazily, `size` required).

```dart title="pickers.dart" tab="file_picker"
import 'package:file_picker/file_picker.dart';
import 'package:giovchat/giovchat.dart';
import 'package:mime/mime.dart';

Future<List<GiovPickedFile>> pickFiles(GiovPickerRequest request) async {
  final result = await FilePicker.platform.pickFiles(allowMultiple: request.maxFiles > 1, withData: true);
  if (result == null) return const [];
  return [
    for (final file in result.files.take(request.maxFiles))
      if (file.bytes != null)
        GiovPickedFile.bytes(
          name: file.name,
          mimeType: lookupMimeType(file.name) ?? 'application/octet-stream',
          bytes: file.bytes!,
        ),
  ];
}
```

```dart title="pickers.dart" tab="image_picker"
import 'package:giovchat/giovchat.dart';
import 'package:image_picker/image_picker.dart';

Future<List<GiovPickedFile>> pickPhotos(GiovPickerRequest request) async {
  final images = await ImagePicker().pickMultiImage(
    limit: request.maxFiles < 2 ? null : request.maxFiles,
    imageQuality: 85,
  );
  return [
    for (final image in images)
      GiovPickedFile.stream(
        name: image.name,
        mimeType: image.mimeType ?? 'image/jpeg',
        size: await image.length(),
        openRead: image.openRead,
      ),
  ];
}
```

Then pass it: `GiovChat(tenantKey: 'YOUR_WORKSPACE_ID', attachmentPicker: pickFiles)`. Files with an unknown type are refused with a message. Uploads show progress in the composer. Images appear inline in a box of their final shape, and other files as rows.

To let everyone, including your team, see an image's shape before it loads, pass `width` and `height` when you know them. You can also pass a small `thumbnail`: a `data:image/jpeg;base64,...` string of an image about 32 px wide, at most 6,000 characters. A larger one isn't used and the image loads in place.

### Picker plugin setup

Each plugin's own README is the reference for its setup. What they need:

| Platform | `image_picker` (photos) | `file_picker` (documents) |
|---|---|---|
| iOS (`ios/Runner/Info.plist`) | `NSPhotoLibraryUsageDescription`; also `NSCameraUsageDescription` if you take photos and `NSMicrophoneUsageDescription` if you record video | `NSPhotoLibraryUsageDescription` if you pick from the photo library (`FileType.image` or `media`); none for documents |
| Android | Nothing (it uses the system photo picker) | Nothing (it uses the system document picker) |
| macOS (both `.entitlements` files) | `com.apple.security.files.user-selected.read-only` | `com.apple.security.files.user-selected.read-only` |
| Web | Works; files come back as bytes or a stream | Works; use `withData: true`, file paths are not available |

For example, in `Info.plist`:

```xml title="ios/Runner/Info.plist"
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose photos to send in the support chat.</string>
<key>NSCameraUsageDescription</key>
<string>Take a photo to send in the support chat.</string>
```

## Unread replies and badges

By default the chat connects only while it's visible. To count replies that arrive while it's closed, keep a controller alive with `keepConnected: true` and `visible: false`. It only resumes an existing conversation and never starts one in the background.

Read the count anywhere with `GiovUnread.watch`, without passing the controller around. Omit the key to sum all workspaces.

```dart title="chat_badge.dart"
ValueListenableBuilder<int>(
  valueListenable: GiovUnread.watch('YOUR_WORKSPACE_ID'),
  builder: (context, unread, _) => Badge(
    isLabelVisible: unread > 0,
    label: Text('$unread'),
    child: const Icon(Icons.chat_bubble_outline),
  ),
)
```

Opening the chat clears the count. To react to changes in code, use `onUnreadCountChange` or `GiovUnread.read('YOUR_WORKSPACE_ID')`.

## Push notifications

1. Add your Apple push key and/or Firebase service account in **Settings → Chat widget → Push notifications**. Notifications are sent from your app, with your keys. See [Push credentials](/docs/mobile#push-credentials).
2. Register each device token with the SDK:

```dart title="push.dart"
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
  await chat.registerPushToken(
    token,
    GiovPushPlatform.fcm, // or GiovPushPlatform.apns for a raw APNs token
    options: GiovPushOptions(
      // APNs only: development builds get sandbox tokens.
      environment: kDebugMode ? GiovPushEnvironment.sandbox : GiovPushEnvironment.production,
    ),
  );
}
FirebaseMessaging.instance.onTokenRefresh.listen((token) => chat.registerPushToken(token, GiovPushPlatform.fcm));
```

`chat` is your `GiovChatController`, or the controller of a widget (`chatKey.currentState?.controller`). `GiovChatState` also has `registerPushToken` directly.

- Pass the raw device token: the APNs token on iOS (or the FCM token if your iOS app uses Firebase Messaging), and the FCM token on Android. Register again whenever the token changes.
- Use `GiovPushEnvironment.sandbox` from iOS debug builds (Xcode installs get sandbox APNs tokens). TestFlight and App Store builds use production.
- It registers straight away when connected, otherwise as soon as the conversation connects, and never throws. It returns a `GiovPushResult`: check `registered`, or `failure` (`GiovPushFailure.noConversation`, `unsupported` or `failed`).
- Before any conversation exists, the call waits for the first one (the visitor's first message). Don't block app start on it. Disposing the controller resolves it as `GiovPushFailure.noConversation`.
- The token is remembered. When the team closes a conversation and the visitor starts a new one, the device is registered for it automatically.
- A tapped notification carries `{type: 'giov.message', tenantId, conversationId, messageId}`, under `giov` on APNs and in `data` on FCM. Open the chat screen and it catches up from history.

On sign-out, call `await chat.unregisterPushToken(token)` before `reset()` or changing `sessionKey`. It works while the chat is closed or disconnected, because it uses the stored session. `reset()` forgets that session and the remembered token.

## Language

The interface follows the device language: English, French, Spanish, Portuguese or German, with English for others. Text you write in the dashboard (greeting, placeholder, bot name) is shown as written.

```dart title="chat.dart"
GiovChat(
  tenantKey: 'YOUR_WORKSPACE_ID',
  locale: 'pt-BR',
  strings: (base) => base.copyWith(sendMessage: 'Enviar', startChat: 'Começar'),
)
```

Replace any string through `copyWith`; empty values are ignored. `giovStrings` exports the built-in tables. With a controller you own, the language is the controller's (`GiovChatController(locale: ...)`) unless you set `locale` or `strings` on `GiovChat`.

## Appearance

- **Theme:** the dashboard setting (`light`, `dark`, `auto`) applies, and `auto` follows the device. Override it with `theme: GiovThemeMode.light`, `dark` or `auto`.
- **Colour:** the dashboard's main colour applies. Override it with `primaryColor`. Text on it switches between white and near-black to stay readable.
- **Fonts and surface colours:** `styleOverrides`.

```dart title="chat_styles.dart"
GiovChat(
  tenantKey: 'YOUR_WORKSPACE_ID',
  styleOverrides: const GiovChatStyleOverrides(
    fontFamily: 'YourAppFont', // Register this font in your app first.
    container: Color(0xFFF8FAFC),
    header: Color(0xFF0F172A),
    headerTitle: GiovTextOverride(color: Colors.white, fontWeight: FontWeight.w700),
    headerSubtitle: GiovTextOverride(color: Color(0xFFCBD5E1)),
    agentBubble: Color(0xFFE2E8F0),
    agentText: GiovTextOverride(color: Color(0xFF0F172A)),
    visitorBubble: Color(0xFF2563EB),
    visitorText: GiovTextOverride(color: Colors.white),
    composer: Color(0xFFF8FAFC),
    input: GiovTextOverride(color: Color(0xFF0F172A)),
    inputBackground: Colors.white,
    button: Color(0xFF2563EB),
    buttonText: GiovTextOverride(color: Colors.white),
  ),
)
```

Slots that take a `Color` set a background. Slots that take a `GiovTextOverride` accept `fontFamily`, `fontWeight`, `fontStyle` and `color`.

| Slots | Type | Applies to |
|---|---|---|
| `container`, `header`, `composer`, `form` | `Color` | Chat surface, header, composer row, pre-chat form |
| `agentBubble`, `visitorBubble` | `Color` | Agent, greeting and typing bubbles; visitor bubbles |
| `button` | `Color` | Send, start-chat and retry buttons |
| `headerTitle`, `headerSubtitle` | `GiovTextOverride` | Header text; the title colour also applies to the close icon |
| `agentText`, `visitorText` | `GiovTextOverride` | Message content, sender labels and timestamps; agent text also covers greeting and typing |
| `formTitle`, `label`, `buttonText`, `hint`, `errorText` | `GiovTextOverride` | Form heading and labels, button text, loading hint, attachment chips, errors; the hint also styles input placeholders |
| `input` | `GiovTextOverride` | Text in the composer and all pre-chat inputs |
| `inputBackground` | `Color` | Background of the composer and pre-chat inputs |

The top-level `fontFamily` is the default for customizable text, and a slot's `fontFamily` takes precedence. Font sizes, spacing, borders, dimensions and the branding footer stay under the SDK's control.

**Branding:** Starter and Pro always show **Powered by GiovChat**. Business and Advanced can turn it off in **Settings → Chat widget → Appearance**. The footer shows unless the workspace explicitly turns it off, and style overrides don't affect its look. If you build your own UI on the controller, honour `controller.config?.showBranding` yourself.

## Lifecycle and errors

- **Background:** when the app goes to the background, the connection stays open for 5 seconds and then closes, so push notifications can reach the device. Change that with `backgroundGrace` on `GiovChatController`. When the app returns, the chat reconnects and catches up on anything it missed.
- **Dropped connections:** the SDK retries with a growing delay and shows a "Connection lost. Reconnecting…" notice. The composer stays editable.
- **Outbox:** messages typed offline or while reconnecting wait in an outbox and go out in order once the connection is back, each under one ID so it's never stored twice. A message that still fails shows **Not sent · Tap to retry**, with a Discard button.
- **Message length:** messages are limited to 2,000 UTF-16 code units. A longer paste is cut at the limit.
- **Errors:** `onError` receives every failure as a `GiovChatException`. Subtypes include `GiovNetworkException`, `GiovSessionExpiredException`, `GiovConversationClosedException`, `GiovRateLimitedException`, `GiovWorkspaceBusyException`, `GiovIdentityRejectedException`, `GiovAttachmentRejectedException`, `GiovSendFailedException`, `GiovStorageException` and `GiovServerException`. Their `message` is for logs, in English. The screen shows its own translated text.

## Reference

### GiovChat

| Parameter | Type | Default | Description |
|---|---|---|---|
| `tenantKey` | `String?` | none | Your workspace ID. Pass this or `controller` |
| `controller` | `GiovChatController?` | none | A controller you own. The widget never disposes it |
| `baseUrl` | `String` | `https://api.giovchat.app` | API base URL override |
| `contact` | `GiovContact?` | none | Signed-in user's details; also pre-fills the pre-chat form |
| `sessionKey` | `String?` | none | Change it to isolate or reset the session (sign-out, account switch) |
| `sessionStore` | `GiovSessionStore?` | in memory | Your secure storage for the session |
| `keepConnected` | `bool` | `false` | Stay connected while hidden to count unread replies |
| `visible` | `bool` | `true` | Whether the chat is on screen. A hidden chat disconnects unless `keepConnected`; showing it clears unread replies |
| `autoStart` | `bool` | `true` | Start the controller on mount. Starting an already started controller does nothing |
| `onClose` | `VoidCallback?` | none | Shows a close button in the header |
| `theme` | `GiovThemeMode?` | From dashboard | `light`, `dark` or `auto`; overrides the dashboard |
| `primaryColor` | `Color?` | From dashboard | Brand colour override |
| `styleOverrides` | `GiovChatStyleOverrides` | none | Allowed font and colour overrides |
| `locale` | `String?` | Device language | Interface language |
| `strings` | `GiovChatStrings Function(GiovChatStrings)?` | none | Replace any interface string |
| `attachmentPicker` | `GiovAttachmentPicker?` | none | Enables attachments, when the agent allows them |
| `onOpenUrl` | `GiovUrlOpener?` | `url_launcher` | How links and attachments open. Only `http(s)` and `mailto` links reach it |
| `safeArea` | `bool` | `true` | Keep clear of notches and the home indicator |
| `onError` | `void Function(GiovChatException)?` | none | Every failure |
| `onConversationStart` | `void Function(String)?` | none | Called with the conversation ID |
| `onConnectionStatusChange` | `void Function(GiovConnectionStatus)?` | none | `connecting`, `connected` or `disconnected` |
| `onUnreadCountChange` | `void Function(int)?` | none | Unread replies while hidden |

`baseUrl`, `contact`, `sessionKey`, `sessionStore`, `keepConnected` and the callbacks configure the controller the widget creates; they are ignored when you pass `controller`, except `contact`, which still pre-fills the form.

`GiovChatState` (through a `GlobalKey<GiovChatState>`) has `controller`, `reset()`, which clears this user's session and starts fresh, and `registerPushToken(token, platform, {options})`.

### GiovChatController

| Member | Description |
|---|---|
| `GiovChatController({required tenantKey, baseUrl, contact, sessionKey, sessionStore, keepConnected, visible, locale, overrideStrings, backgroundGrace, onError, onConversationStart, onConnectionStatusChange, onUnreadCountChange})` | Creates a controller. `keepConnected` defaults to `false`, `visible` to `true`, `backgroundGrace` to 5 seconds |
| `start()` | Loads the configuration and resumes a stored conversation. Does nothing if already started |
| `retry()` | Starts over after an error, or forces a fresh connection |
| `reset()` | Forgets the conversation, stored session and outbox |
| `setVisible(bool)` | Shows or hides the chat; showing clears unread replies |
| `handleLifecycle(state)` | Forward `AppLifecycleState` changes here when you build your own UI |
| `send(text)` | Sends a message |
| `registerPushToken(token, platform, {options})` | Registers the device for push. Never throws |
| `unregisterPushToken(token)` | Stops push for this device. Works while the chat is closed |
| `dispose()` | Releases the controller. Call it yourself when you own it |

State to read from it: `phase` (`GiovChatPhase`), `messages`, `header`, `agentTyping`, `unreadCount`, `error`, `connectionStatus`, `conversationId`, `config` and `uploads`.
