# In-app кампании в headless-режиме Flutter SDK

Этот гайд описывает интеграцию in-app кампаний в существующее Flutter-приложение с собственной отрисовкой. SDK получает контент и выбирает кампанию, а приложение показывает сообщение, обрабатывает действия и передаёт статистику.

Ниже — фрагменты одного интеграционного модуля `lib/gravity_in_app.dart`: получение ответа, контроллер показа, рендеринг modal и аналитика. Их можно перенести в проект клиента без отдельного приложения. Роутер, WebView и push-провайдер подключаются через функции вашего приложения.

Инструкция рассчитана на **gravity_sdk 0.25.0**, Flutter от **3.38.1** и Dart **^3.10.0**. Передача задержки кампании в headless-ответе появится **в ближайшем релизе Flutter SDK**; ниже отдельно описано текущее и предстоящее поведение.

## Что реализовать

| SDK | Приложение |
|---|---|
| Отправляет просмотр экрана или бизнес-событие | Вызывает метод с актуальным `PageContext` |
| Выбирает кампанию по приоритету и загружает её контент | Проверяет поддержку формата и элементов |
| Возвращает типизированные модели и исходный JSON | Применяет задержку, проверяет актуальность и блокировки |
| Отправляет tracking загрузки контента | Рисует контейнер и элементы, выполняет действия |
| Предоставляет методы engagement | Фиксирует фактический показ, видимость, клики и закрытия |

Одна кампания проходит цепочку: **просмотр или событие → выбор и загрузка контента → подготовка UI и ожидание → проверка актуальности → показ → действия и статистика**. Отсутствие контента, неподдерживаемая схема или неактуальный экран завершают её без показа. Основной экран приложения продолжает работать.

## 1. Подключите SDK

Для начала нужны `apiKey` и `section` вашего проекта. Получите их у команды Gravity Field. Android release-сборке требуется разрешение `android.permission.INTERNET`. [Платформенные настройки](./ask_flutter.md#установка).

Добавьте зависимости и выполните `flutter pub get`:

```yaml
dependencies:
  flutter:
    sdk: flutter
  gravity_sdk: 0.25.0
  visibility_detector: ^0.4.0+2

flutter:
  uses-material-design: true
```

В начале `lib/gravity_in_app.dart` добавьте импорты. Префикс `gf` исключает конфликты имён SDK и Flutter:

```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gravity_sdk/gravity_sdk.dart' as gf;
import 'package:visibility_detector/visibility_detector.dart';
```

В существующем `main()` до `runApp()`:

```dart
WidgetsFlutterBinding.ensureInitialized();
await gf.GravitySDK.instance.initialize(
  apiKey: 'YOUR_API_KEY',
  section: 'YOUR_SECTION_ID',
  logLevel: gf.LogLevel.info,
);
gf.GravitySDK.instance.setOptions(isFetchContentOnTrack: true);
```

Если инициализация находится в другом файле, добавьте в него импорты Flutter и SDK с тем же префиксом. `initialize()` вызывайте один раз при запуске. До первого запроса настройте [пользователя](./identity.md); перед выходом и сменой аккаунта отменяйте собственные показы по правилам ниже.

При `isFetchContentOnTrack: false` методы `NoShow` отправляют просмотр или событие, но не загружают контент и возвращают `null`. Для собственного UI используйте `trackViewNoShow()` и `triggerEventNoShow()`. Обычные `trackView()` и `triggerEvent()` могут запустить встроенный показ SDK.

Для проверки нужен опубликованный контент, доступный для вашего контекста. Его подготовку можно передать команде Gravity Field: сообщите экран, событие, поддерживаемые форматы и тестовый SKU. Дальнейшие шаги выполняются в коде приложения.

## 2. Получите контент

При открытии экрана передайте просмотр. Вызывайте метод из логики жизненного цикла экрана, а не из `build()`:

```dart
final productContext = gf.PageContext(
  type: gf.ContextType.product,
  data: ['sku-123'],
  location: 'app://product/sku-123',
);

final details = await gf.GravitySDK.instance.trackViewNoShow(
  pageContext: productContext,
);
```

После успешного бизнес-действия, например изменения корзины:

```dart
final details = await gf.GravitySDK.instance.triggerEventNoShow(
  events: [
    gf.AddToCartEvent(
      value: 1990,
      productId: 'sku-123',
      quantity: 1,
      currency: 'RUB',
    ),
  ],
  pageContext: productContext,
);
```

Оба метода возвращают `Future<GravityDataResponse<ContentResponse>?>` и не требуют `BuildContext`. `details.data` содержит модель `ContentResponse`, `details.json` — исходный ответ с контентом. SKU в `data` должен совпадать с фидом, включая регистр.

`null` означает, что контент не получен: нет подходящей кампании, загрузка отключена, ответ устарел, событие осталось в офлайн-очереди или SDK обработал ошибку. Возврат событийного метода сам по себе не подтверждает доставку события. Подробнее: [офлайн-доставка](./offline.md).

Если при инициализации задан `gravityContentCallback`, он тоже получает подробный ответ. Выберите один путь показа: возвращённое значение или callback. Иначе один ответ может открыть два in-app. В этом гайде используется возвращённое значение.

### Выбор по приоритету

SDK сортирует активированные кампании по `priority` от большего к меньшему; отсутствующий приоритет считается равным `0`. Затем запрашивает контент по очереди до первой кампании с корневым контентом. Если кампания не прошла таргетинг или корневого контента нет, SDK проверяет следующую.

Приложению не нужно перебирать кампании и повторно вызывать `getContentByCampaignId()`. Если формат уже выбранной кампании не поддерживается вашим UI, пропустите показ. Согласуйте поддерживаемые форматы с командой, которая настраивает кампании.

### Корневой контент и вариация

Контент находится по цепочке `ContentResponse.data → Campaign.payload → CampaignVariation.contents`. Для первого экрана выбирайте `step == null`. Сохраните исходные кампанию, вариацию и контент: они понадобятся для статистики, переходов между шагами и отправки формы.

```dart
class InAppSelection {
  const InAppSelection(this.campaign, this.variation, this.content);
  final gf.Campaign campaign;
  final gf.CampaignVariation variation;
  final gf.CampaignContent content;
}

InAppSelection? selectInAppRoot(gf.ContentResponse response) {
  for (final campaign in response.data) {
    for (final variation in campaign.payload) {
      for (final content in variation.contents) {
        if (content.step == null) {
          return InAppSelection(campaign, variation, content);
        }
      }
    }
  }
  return null;
}
```

`InAppSelection` и `selectInAppRoot()` — код интеграционного модуля, а не API SDK. После NoShow получите контент через `details == null ? null : selectInAppRoot(details.data)`. После выбора проверьте формат и схему перед отрисовкой. Пустой список, отсутствующее поле или неподдерживаемое значение не должны приводить к падению или блокировке экрана.

### Контрольный ответ с контентом

Для локальной проверки разбора можно использовать сокращённый ответ `/choose` ниже. Идентификаторы условные, `events` пуст: ответ позволяет проверить рендеринг, но не доставку аналитики. Для сетевой проверки используйте реальный ответ SDK с tracking вашей кампании.

```json
{
  "user": {
    "uid": "test-user",
    "ses": "test-session"
  },
  "data": [
    {
      "payload": [
        {
          "campaignId": "test-campaign",
          "experienceId": "test-experience",
          "variationId": "test-variation",
          "decisionId": "test-decision",
          "contents": [
            {
              "contentId": "test-content",
              "contentType": "native",
              "deliveryMethod": "modal",
              "variables": {
                "frameUI": {
                  "container": {
                    "style": {
                      "backgroundColor": "#FFFFFF",
                      "padding": {
                        "left": 24,
                        "right": 24,
                        "top": 24,
                        "bottom": 24
                      }
                    }
                  },
                  "close": {}
                },
                "elements": [
                  {
                    "type": "text",
                    "text": "Ваш персональный промокод",
                    "style": {
                      "fontSize": 20,
                      "textColor": "#222222"
                    }
                  },
                  {
                    "type": "button",
                    "text": "Скопировать",
                    "style": {
                      "layoutWidth": "match_parent",
                      "size": {
                        "height": 48
                      }
                    },
                    "onClick": {
                      "action": "copy",
                      "copyData": "WELCOME",
                      "closeOnClick": true
                    }
                  }
                ]
              },
              "events": []
            }
          ]
        }
      ]
    }
  ]
}
```

Проверить модель можно через `gf.ContentResponse.fromJson(jsonDecode(jsonText) as Map<String, dynamic>)`, где `jsonText` — строка с этим ответом; для этого импортируйте `dart:convert`. В рабочей интеграции используйте `details.data`: повторно разбирать `details.json` не требуется.

## 3. Подготовьте показ

### Задержка кампании

В **0.25.0** методы `NoShow` возвращают контент без ожидания задержки кампании. Значение задержки в их результате пока недоступно: применить настройку отложенного показа из кампании через этот ответ нельзя.

**В ближайшем релизе Flutter SDK** оба метода будут возвращать вместе с контентом задержку `delayTime` выбранной кампании в миллисекундах. Номер версии и формат ответа будут указаны в описании релиза.

После обновления приложение должно:

1. Получить контент и задержку выбранной кампании.
2. При значении больше `0` подождать указанное время после получения ответа, например через `Future.delayed`. При `0` или отсутствии значения перейти к следующему шагу сразу.
3. Повторно проверить актуальность и разрешение показа.
4. Открыть собственный UI.

SDK не будет делать паузу внутри `NoShow`. Код ожидания подключайте после обновления на версию с передачей задержки.

### Актуальность экрана и пользователя

Непосредственно перед показом, в том числе после загрузки изображений и задержки, проверьте:

- экран существует (`mounted`), активен и не перекрыт другим маршрутом, включая вложенный `Navigator`;
- пользователь не сменился: не было входа, выхода, `setUser()`, `resetUser()`, `restoreUserId()` или замены пользователя;
- контекст не изменился: например, на карточке открыт тот же SKU;
- ответ относится к актуальному запросу и не заменён более новым;
- приложение активно, показ разрешён и другой in-app не открыт.

`mounted` подтверждает только существование виджета. Для маршрутов используйте состояние своего роутера. Для контекста и пользователя сохраните снимок при начале запроса или используйте счётчик поколения. При уходе с экрана, смене пользователя или товара увеличивайте счётчик; ответ с прежним значением пропускайте. Пример защиты от запоздавшего ответа есть в [BDUI-гайде](./flutter_bdui_guide.md).

В опубликованном SDK 0.25.0 `staleContentTimeout` проверяется после ответа на просмотр или событие. Приложение дополнительно проверяет пользователя, экран и общий возраст ответа после загрузки контента и перед показом. Для пропущенного показа не отправляйте impression и visible impression.

### Блокировки и одновременные ответы

`lockPresentation()` управляет встроенным автопоказом SDK. Для собственного UI создайте общий контроллер приложения, который знает, разрешён ли показ и открыт ли in-app.

Например, блокируйте новый показ на время оплаты, авторизации или собственного диалога. Продолжайте отправлять просмотры и бизнес-события. Контент, пришедший во время блокировки, пропускайте; после снятия блокировки ждите нового просмотра или события.

Перед открытием UI проверяйте и занимайте место без промежуточного `await`, чтобы два одновременных ответа не открыли два окна. Освобождайте место при закрытии и ошибке через `finally`. Если блокирующие сценарии пересекаются, учитывайте каждый: завершение одного не должно снимать остальные блокировки.

Уже открытый in-app при включении блокировки закрывайте только по правилам своего сценария. Ожидающий показ отменяйте, если экран или пользователь стали неактуальны.

### Подключите функции приложения

Навигацию и push-разрешения оставьте в существующих сервисах приложения. Опишите их контракт в интеграционном модуле:

```dart
class InAppActions {
  const InAppActions({
    required this.openUrl,
    required this.openDeeplink,
    required this.requestPush,
    this.onCopied,
  });
  final Future<void> Function(String url, gf.FollowUrlType type) openUrl;
  final Future<void> Function(String deeplink) openDeeplink;
  final Future<gf.NotificationPermissionStatus> Function() requestPush;
  final VoidCallback? onCopied;
}
```

| Функция | Что передать |
|---|---|
| `openUrl(url, type)` | Открытие URL во внешнем браузере при `gf.FollowUrlType.browser` или во внутреннем WebView при `webview`; Future завершается после передачи управления |
| `openDeeplink(deeplink)` | Обработчик диплинков вашего роутера; завершается после передачи управления |
| `requestPush()` | Запрос разрешения через используемый push-пакет; возвращает `granted`, `denied` или `unknown` |
| `onCopied` | Необязательное подтверждение копирования через toast/Snackbar приложения |

Создайте `InAppActions` с этими функциями один раз в слое интеграции. Это адаптеры к вашему приложению: SDK не предоставляет роутер или push-провайдер. Проверяйте адрес и результат открытия в соответствующем адаптере. При ошибке функция должна завершаться исключением, которое обработчик in-app запишет в лог.

### Контроллер показа

Создайте один `InAppCoordinator` на приложение для всех собственных in-app. Он отправляет трекинг даже при блокировке UI, ограничивает возраст ответа через `maxResponseAge` (по умолчанию 10 секунд), пропускает устаревшие ответы и удерживает место до завершения маршрута сообщения.

```dart
class InAppCoordinator {
  InAppCoordinator({this.maxResponseAge = const Duration(seconds: 10)});
  final Duration maxResponseAge;
  final Set<Object> _blocks = {};
  int _epoch = 0;
  int _request = 0;
  bool _busy = false;
  VoidCallback? _dismiss;

  // Перед сменой аккаунта, SKU или уходом с исходного экрана.
  void invalidate() {
    _epoch++;
    _request++;
    _dismiss?.call();
  }

  // Токен позволяет независимо блокировать оплату, диалог и другие сценарии.
  void block(Object token) {
    _blocks.add(token);
    _request++;
  }

  void unblock(Object token) {
    _blocks.remove(token);
    _request++; // Старый ответ не появится после разблокировки.
  }

  Future<void> loadAndPresent({
    required BuildContext context,
    required bool Function() isScreenCurrent,
    required Future<gf.GravityDataResponse<gf.ContentResponse>?> Function()
    load,
    required InAppActions actions,
  }) async {
    final elapsed = Stopwatch()..start();
    final epoch = _epoch;
    final request = ++_request;
    bool foreground() =>
        WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
    final allowedAtStart =
        !_busy &&
        _blocks.isEmpty &&
        context.mounted &&
        isScreenCurrent() &&
        foreground();
    bool sameContext() => epoch == _epoch && context.mounted;
    bool canOpen() =>
        allowedAtStart &&
        sameContext() &&
        elapsed.elapsed <= maxResponseAge &&
        request == _request &&
        _blocks.isEmpty &&
        isScreenCurrent() &&
        foreground();
    try {
      // Просмотр/событие отправляется даже при блокировке собственного UI.
      final details = await load();
      if (details == null || !canOpen() || _busy) return;
      final selected = selectInAppRoot(details.data);
      if (selected == null || !supportsModal(selected.content)) return;
      _busy = true; // Занимаем место до следующего await.
      try {
        await presentClientModal(
          context: context,
          selected: selected,
          actions: actions,
          canOpen: canOpen,
          sameContext: sameContext,
          registerDismiss: (dismiss) => _dismiss = dismiss,
        );
      } finally {
        _dismiss = null;
        _busy = false;
      }
    } catch (error, stack) {
      debugPrint('In-app: $error\n$stack');
    }
  }
}
```

Подключите контроллер к жизненному циклу:

| Момент | Вызов |
|---|---|
| Перед входом, выходом, сменой пользователя SDK или SKU | `inApp.invalidate()` до изменения состояния и вызова SDK |
| Перед уходом с исходного экрана или перекрытием маршрутом приложения | `inApp.invalidate()` |
| Приложение перешло из `resumed` в другое состояние | `inApp.invalidate()` из существующего lifecycle observer |
| Начало оплаты, авторизации, собственного диалога | `inApp.block(token)` с отдельным объектом-токеном на сценарий |
| Завершение блокирующего сценария | `inApp.unblock(token)` в `finally` |
| Удаление владельца интеграции | `inApp.invalidate()` и отписка от ваших observers |

`invalidate()` отменяет ожидающие ответы и закрывает собственный маршрут без пользовательского close. `block()` запрещает новые показы, сохраняя уже открытый. Снятие блокировки не запускает старую кампанию.

В обработчике навигации различайте маршруты приложения и маршрут самого in-app с именем `gravity-in-app`. Его открытие не должно вызывать `invalidate()`, иначе контроллер сразу закроет своё сообщение. В приложении с вложенными `Navigator` проверяйте всю цепочку активных маршрутов.

## 4. Отрисуйте формат и контейнер

Формат определяется `content.deliveryMethod`. В таблицах ниже приведены **имена значений Dart-моделей SDK**: например, `DeliveryMethod.bottomSheet` соответствует `bottom_sheet` в исходном JSON.

| Формат | `deliveryMethod` | Контейнер приложения |
|---|---|---|
| Snackbar | `snackBar` | Короткое сообщение поверх экрана |
| Модальное окно | `modal` | Окно по центру с затемнением фона |
| Bottom sheet | `bottomSheet` | Нижняя выдвижная панель |
| Оверлей | `fullScreen` | Полноэкранное сообщение |
| Тултип | `tooltip` | Подсказка рядом с элементом экрана |
| Inline-блок | `inline` | Размещение в вёрстке; не открывайте как in-app |
| JSON | `json` | Данные для отдельного сценария; не открывайте как визуальный in-app |
| Неизвестный | `unknown` | Пропустите показ |

Параметры внешнего блока находятся в `content.variables.frameUI`:

| Поле | Что применить |
|---|---|
| `frameUI.container.style` | Фон и фоновое изображение, вписывание, скругление, выравнивание, внутренние отступы |
| `frameUI.close` | Кнопка закрытия и её положение; у Snackbar её нет |
| `frameUI.dragHandle` | Ручка bottom sheet |
| `frameUI.arrow` | Стрелка тултипа |

Для Snackbar учитывайте `content.templateSystemName` (`snackbar-1` или `snackbar-2`). Длительность задаётся в **секундах** через `frameUI.params.duration`; при отсутствии значения встроенный UI SDK использует 4 секунды. Это время видимости сообщения, отдельно от задержки перед показом.

Для тултипа храните соответствие selector и виджета на экране. Selector якоря должен совпадать с `Campaign.selector`. Сторону, выравнивание, переворот при нехватке места и зазор берите из `content.variables.tooltipConfig` и `content.variables.positioning`. Если якоря нет, пропустите показ.

## 5. Отрисуйте элементы

Стройте содержимое из `content.variables.elements` в порядке ответа. Тип задаёт `element.type`, оформление — `element.style`, действие — `element.onClick`. SDK уже преобразует цвета, начертание и вписывание в типы Flutter: `Color`, `FontWeight`, `BoxFit`.

| Элемент | `element.type` | Данные для отрисовки |
|---|---|---|
| Текст | `text` | `text`, размер, начертание, цвет, выравнивание, ширина и отступы |
| Кнопка | `button` | `text`, фон и цвет при нажатии, скругление, размер, ширина, отступы, оформление текста |
| Изображение | `image` | `src`, размер, ширина макета, скругление, вписывание, отступы |
| Отступ | `spacer` | Размер пустого пространства |
| Товары | `productsContainer` | `content.products.slots`, строка или сетка, число колонок, размеры и отступы |
| Webview | `webview` | `src`, размер, ширина, скругление, отступы |

Товары рисуйте в порядке `slots`, атрибуты берите из `slot.item` согласно вашему фиду. Сохраняйте исходный `Slot` для статистики товара. Пустой товарный блок не отображайте, неизвестный элемент пропускайте. Если после этого нет пригодного содержимого, пропустите весь in-app.

Если рендерер ещё не поддерживает формы, пропускайте кампании с `optionSelect`, `textInput` или действием `submitForm`. Иначе пользователь увидит опрос, который не сможет заполнить или отправить.

Для стандартных полей используйте типизированные модели. `content.rawVariables` нужен для дополнительных ключей вашего приложения. Весь исходный JSON доступен в `details.json`; порядок работы с произвольными данными описан в [общем headless-гайде](./flutter_headless_guide.md).

### Изображения и дизайн-система

Загружайте изображения по URL из `element.src`, `frameUI.container.style.backgroundImage` и атрибутов `slot.item`. По возможности подготовьте их до открытия окна, затем повторите проверки актуальности.

Размеры `style.size` применяйте в логических пикселях. При макете на всю ширину изображение занимает ширину контейнера; вписывание берите из `style.fit`, скругление — из `style.cornerRadius`.

Цвета, размеры, начертания и отступы из редактора применяйте к UI. Параметры, которые определяет приложение, согласуйте со своей дизайн-системой:

- шрифт и замену отсутствующих начертаний;
- межстрочный интервал и поведение при увеличенном системном шрифте;
- ограничение строк для коротких сообщений и прокрутку длинного содержимого;
- максимальную ширину окна на планшетах и высоту относительно экрана;
- анимации, светлую и тёмную темы.

Проверяйте, что текст и кнопка закрытия доступны на маленьком экране и при крупном шрифте. Настройки визуального редактора описаны в [Кастомизации in-app](/personalization/Campaigns/app_campaigns/in_app_customization.md).

### Рендеринг modal

Следующий фрагмент добавьте в тот же модуль. Он рисует текст, кнопку, изображение и отступ. Читает nullable-поля стиля с fallback, фон контейнера и его отступы. Кнопка закрытия здесь использует крестик дизайн-системы в правом верхнем углу; максимальная ширина 560 — правило примера для планшета. Для точного воспроизведения редактора примените также `frameUI.close.image`, `style.positioned` и `style.size` кнопки закрытия.

```dart
Widget buildClientModal(
  gf.CampaignContent content,
  void Function(gf.OnClick action, bool isButton) onAction,
  VoidCallback onClose, {
  required Key visibilityKey,
  required ValueChanged<double> onVisibility,
}) {
  final frame = content.variables.frameUI;
  final style = frame?.container.style;
  EdgeInsets padding(gf.GravityPadding? p) => EdgeInsets.fromLTRB(
    p?.left ?? 0,
    p?.top ?? 0,
    p?.right ?? 0,
    p?.bottom ?? 0,
  );
  EdgeInsets margin(gf.GravityMargin? m) => EdgeInsets.fromLTRB(
    m?.left ?? 0,
    m?.top ?? 0,
    m?.right ?? 0,
    m?.bottom ?? 0,
  );
  Widget element(gf.Element e) {
    final s = e.style;
    final action = e.onClick;
    final button = e.type == gf.ElementType.button;
    final ts = s?.textStyle;
    final textStyle = TextStyle(
      color: button ? ts?.color : s?.textColor,
      fontSize: button ? ts?.fontSize : s?.fontSize,
      fontWeight: button ? ts?.fontWeight : s?.fontWeight,
    );
    Widget child;
    switch (e.type) {
      case gf.ElementType.text:
        child = Padding(
          padding: padding(s?.padding),
          child: Text(
            e.text ?? '',
            style: textStyle,
            textAlign: switch (s?.contentAlignment) {
              gf.GravityContentAlignment.center => TextAlign.center,
              gf.GravityContentAlignment.end => TextAlign.end,
              _ => TextAlign.start,
            },
          ),
        );
      case gf.ElementType.button:
        child = FilledButton(
          style: FilledButton.styleFrom(
            backgroundColor: s?.backgroundColor,
            foregroundColor: ts?.color,
            overlayColor: s?.pressColor,
            minimumSize: Size(0, s?.size?.height ?? 40),
            padding: padding(s?.padding),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(s?.cornerRadius ?? 8),
            ),
          ),
          onPressed: action == null ? null : () => onAction(action, true),
          child: Text(e.text ?? '', style: textStyle),
        );
      case gf.ElementType.image:
        child = ClipRRect(
          borderRadius: BorderRadius.circular(s?.cornerRadius ?? 0),
          child: Image.network(
            e.src ?? '',
            width: s?.size?.width,
            height: s?.size?.height,
            fit: s?.fit ?? BoxFit.cover,
            errorBuilder: (_, _, _) => const SizedBox.shrink(),
          ),
        );
      case gf.ElementType.spacer:
        child = SizedBox(width: s?.size?.width, height: s?.size?.height ?? 0);
      default:
        return const SizedBox.shrink();
    }
    if (!button && action != null) {
      child = GestureDetector(
        onTap: () => onAction(action, false),
        child: child,
      );
    }
    return Padding(
      padding: margin(s?.margin),
      child: SizedBox(
        width: s?.layoutWidth == gf.GravityLayoutWidth.matchParent
            ? double.infinity
            : null,
        child: child,
      ),
    );
  }

  return Dialog(
    clipBehavior: Clip.antiAlias,
    backgroundColor: style?.backgroundColor ?? Colors.white,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(style?.cornerRadius ?? 0),
    ),
    child: VisibilityDetector(
      key: visibilityKey,
      onVisibilityChanged: (info) => onVisibility(info.visibleFraction),
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 560),
        child: Stack(
          children: [
            if (style?.backgroundImage case final String url)
              Positioned.fill(
                child: Image.network(
                  url,
                  fit: style?.backgroundFit ?? BoxFit.cover,
                  errorBuilder: (_, _, _) => const SizedBox.shrink(),
                ),
              ),
            SingleChildScrollView(
              child: Padding(
                padding: padding(style?.padding),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment:
                      style?.contentAlignment?.toCrossAxisAlignment() ??
                      CrossAxisAlignment.center,
                  children: [
                    for (final e
                        in content.variables.elements ?? <gf.Element>[])
                      element(e),
                  ],
                ),
              ),
            ),
            if (frame?.close != null)
              Positioned(
                top: 0,
                right: 0,
                child: IconButton(
                  onPressed: onClose,
                  tooltip: 'Закрыть',
                  icon: const Icon(Icons.close),
                ),
              ),
          ],
        ),
      ),
    ),
  );
}
```

Перед показом ограничьте поддерживаемый контракт. Эта реализация принимает только modal без форм, товаров, WebView, многошаговых действий и клика по контейнеру:

```dart
bool supportsModal(gf.CampaignContent content) {
  if (content.deliveryMethod != gf.DeliveryMethod.modal) return false;
  final elements = content.variables.elements;
  if (elements == null || elements.isEmpty) return false;
  const types = {
    gf.ElementType.text,
    gf.ElementType.button,
    gf.ElementType.image,
    gf.ElementType.spacer,
  };
  const actions = {
    gf.Action.close,
    gf.Action.cancel,
    gf.Action.copy,
    gf.Action.followUrl,
    gf.Action.followDeeplink,
    gf.Action.requestPush,
  };
  return elements.any((e) => e.type != gf.ElementType.spacer) &&
      content.variables.frameUI?.container.onClick == null &&
      elements.every(
        (e) =>
            types.contains(e.type) &&
            e.visibleWhen == null &&
            (e.onClick == null || actions.contains(e.onClick!.action)),
      );
}
```

Расширяйте `supportsModal()` одновременно с рендерером и обработчиком действий. Это проверка возможностей приложения; остальные форматы SDK описаны в таблице выше. Размеры и цвета по умолчанию определяет тема Flutter. Если вашей дизайн-системе нужны другие fallback, задайте их явно.

## 6. Обработайте действия

Действие может быть у кнопки, текста, изображения или контейнера. Тип находится в `onClick.action`.

| Действие | Значение Dart | Что выполнить |
|---|---|---|
| Закрыть | `close` | Закрыть in-app и отправить явное закрытие |
| Отменить | `cancel` | Закрыть без перехода |
| Открыть URL | `followUrl` | Открыть `onClick.url`: внешний браузер при `type == browser`, внутренний WebView при `type == webview` |
| Открыть диплинк | `followDeeplink` | Передать `onClick.deeplink` роутеру приложения |
| Копировать | `copy` | Скопировать `onClick.copyData` и показать подтверждение |
| Запросить push | `requestPush` | Запросить разрешение через push-пакет приложения и передать результат в `setNotificationPermissionStatus()` |
| Следующий шаг | `openStep` | Открыть контент с номером `onClick.step` в той же вариации |
| Отправить форму | `submitForm` | Проверить поля и выполнить [отправку опроса](#9-добавьте-опросы-при-необходимости) |
| Неизвестное | `unknown` | Не выполнять действие и не закрывать in-app |

Сначала вызовите отправку статистики нажатия, затем выполните действие. Доставка статистики не должна блокировать навигацию. Защитите обработчик от повторного нажатия до завершения действия. Диплинк открывайте после закрытия in-app, чтобы сообщение не перекрыло новый экран.

`onClick.closeOnClick` по умолчанию равен `true`. Для поведения, совместимого со встроенным UI SDK, применяйте его к кнопкам модального окна, bottom sheet и оверлея, а в Snackbar и тултипе — к любому элементу с действием. `openStep` не закрывает in-app; `submitForm` выполняет собственный маршрут после отправки. Неподдерживаемое действие не должно автоматически закрывать окно.

В собственном UI действия выполняет ваш обработчик нажатия. `FollowUrlEvent`, `FollowDeeplinkEvent`, `CopyEvent`, `CancelEvent` и `RequestPushEvent` вызываются встроенным обработчиком SDK; вызов `sendContentEngagement()` не выполняет действие и не создаёт эти callback за приложение.

### Показ маршрута и обработчик нажатий

Этот фрагмент связывает контроллер и рендерер. Он использует `Navigator.of(context)` исходного экрана. Если приложение показывает in-app в корневом Navigator, выберите его и согласуйте с ним проверки маршрутов. При закрытии удаляется именно маршрут сообщения, поэтому чужой диалог поверх него не будет закрыт случайно.

```dart
Future<void> presentClientModal({
  required BuildContext context,
  required InAppSelection selected,
  required InAppActions actions,
  required bool Function() canOpen,
  required bool Function() sameContext,
  required void Function(VoidCallback) registerDismiss,
}) async {
  final navigator = Navigator.of(context);
  late final DialogRoute<void> route;
  var closing = false;
  var handling = false;
  final exposure = InAppExposure(selected);
  final bodyKey = GlobalKey();
  bool alive() =>
      !closing &&
      sameContext() &&
      route.isCurrent &&
      WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
  void dismiss({bool explicit = false}) {
    if (closing) return;
    closing = true;
    if (explicit && sameContext()) exposure.explicitClose();
    // Удаляем именно in-app, не маршрут, открытый поверх него.
    if (navigator.mounted && route.isActive) navigator.removeRoute(route);
  }

  Future<void> handle(gf.OnClick action, {required bool isButton}) async {
    if (handling || !alive()) return;
    handling = true;
    try {
      if (action.action == gf.Action.close) {
        dismiss(explicit: true);
        return;
      }
      exposure.click();
      if (action.action == gf.Action.cancel ||
          action.action == gf.Action.followDeeplink ||
          (isButton && action.closeOnClick))
        dismiss();
      if (closing) await route.popped;
      if (!sameContext()) return;
      switch (action.action) {
        case gf.Action.followUrl:
          if (action.url != null)
            await actions.openUrl(action.url!, action.type);
        case gf.Action.followDeeplink:
          if (action.deeplink != null)
            await actions.openDeeplink(action.deeplink!);
        case gf.Action.copy:
          if (action.copyData != null) {
            await Clipboard.setData(ClipboardData(text: action.copyData!));
            if (sameContext()) actions.onCopied?.call();
          }
        case gf.Action.requestPush:
          final status = await actions.requestPush();
          if (sameContext()) {
            gf.GravitySDK.instance.setNotificationPermissionStatus(status);
          }
        default:
          break;
      }
    } catch (error, stack) {
      debugPrint('In-app action: $error\n$stack');
    } finally {
      handling = false;
    }
  }

  route = DialogRoute<void>(
    context: context,
    settings: const RouteSettings(name: 'gravity-in-app'),
    builder: (dialogContext) {
      // Первый кадр учитывает и короткий показ до callback VisibilityDetector.
      void reportFirstVisibleFrame(Duration _) {
        if (!alive()) return;
        if ((route.animation?.value ?? 1) == 0) {
          WidgetsBinding.instance.addPostFrameCallback(reportFirstVisibleFrame);
          return;
        }
        final box = bodyKey.currentContext?.findRenderObject();
        if (box is! RenderBox || !box.hasSize) return;
        final rect = box.localToGlobal(Offset.zero) & box.size;
        final viewport = Offset.zero & MediaQuery.sizeOf(dialogContext);
        final visible = rect.intersect(viewport);
        final area = rect.width * rect.height;
        if (area > 0 && !visible.isEmpty) {
          exposure.visibility(
            (visible.width * visible.height / area).clamp(0, 1),
          );
        }
      }

      WidgetsBinding.instance.addPostFrameCallback(reportFirstVisibleFrame);
      return buildClientModal(
        selected.content,
        (action, isButton) => unawaited(handle(action, isButton: isButton)),
        () => dismiss(explicit: true),
        visibilityKey: bodyKey,
        onVisibility: (fraction) {
          if (alive() && (route.animation?.value ?? 1) > 0) {
            exposure.visibility(fraction);
          }
        },
      );
    },
  );
  if (!canOpen()) return;
  registerDismiss(() => dismiss());
  try {
    await navigator.push(route);
  } finally {
    closing = true;
    exposure.finish();
  }
}
```

`handling` защищает от повторного нажатия во время действия, `closing` — от повторного закрытия. Системный «Назад» и нажатие на фон завершают маршрут без явного close. Для форм применяйте правила из раздела об опросах.

## 7. Передайте статистику

Получение контента не считается показом. SDK отправляет tracking загрузки, а приложение передаёт фактические взаимодействия через `sendContentEngagement()` и `sendProductEngagement()` с исходными объектами ответа.

| Событие | Когда отправлять | Частота |
|---|---|---|
| `ContentImpressionEngagement(content, campaign)` | In-app появился на экране | Один раз на открытие |
| `ContentVisibleImpressionEngagement(content, campaign)` | Видно не менее 50% in-app | Один раз на открытие |
| `ContentClickEngagement(content, campaign)` | Нажатие на элемент с поддерживаемым действием, кроме `close`; отправка формы учитывается отдельно | На каждое принятое нажатие |
| `ContentCloseEngagement(content, campaign)` | Явная кнопка закрытия или действие `close` | Один раз на открытие |
| `ProductVisibleImpressionEngagement(slot, content, campaign)` | Видно не менее 50% карточки товара | Один раз на товар в показанном размещении |
| `ProductClickEngagement(slot, content, campaign)` | Нажатие на карточку товара | На каждое принятое нажатие |

Чтобы учитывать закрытия обычных сообщений так же, как встроенный UI, не отправляйте close при системном «Назад», свайпе, нажатии на фон, истечении времени Snackbar или автоматическом закрытии после действия. Для опросов действует отдельное правило: [закрытие опроса](#9-добавьте-опросы-при-необходимости).

Для каждого открытия создавайте один `InAppExposure` вне `build()`. Он используется в `presentClientModal()` выше:

```dart
class InAppExposure {
  InAppExposure(this.selected);
  final InAppSelection selected;
  bool _shown = false;
  bool _visible = false;
  bool _closed = false;

  void visibility(double fraction) {
    if (_closed ||
        WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed)
      return;
    if (fraction > 0 && !_shown) {
      _shown = true;
      gf.GravitySDK.instance.sendContentEngagement(
        gf.ContentImpressionEngagement(selected.content, selected.campaign),
      );
    }
    if (fraction >= 0.5 && !_visible) {
      _visible = true;
      gf.GravitySDK.instance.sendContentEngagement(
        gf.ContentVisibleImpressionEngagement(
          selected.content,
          selected.campaign,
        ),
      );
    }
  }

  void click() => gf.GravitySDK.instance.sendContentEngagement(
    gf.ContentClickEngagement(selected.content, selected.campaign),
  );

  void explicitClose() {
    if (_closed) return;
    _closed = true;
    gf.GravitySDK.instance.sendContentEngagement(
      gf.ContentCloseEngagement(selected.content, selected.campaign),
    );
  }

  void finish() => _closed = true;
}
```

`visibility()` вызывается по первому кадру и изменениям `VisibilityDetector`; флаги предотвращают повторную отправку. Проверка первого кадра учитывает короткое открытие до отложенного callback detector. `finish()` запрещает поздний учёт закрытого сообщения. `explicitClose()` вызывайте только для пользовательского закрытия.

Для модального окна, bottom sheet и оверлея фиксируйте показ после первого кадра содержимого в активном приложении, например через `addPostFrameCallback`. Передавайте `1.0`, только если содержимое действительно видно целиком. Если окно закрылось до первого кадра или приложение неактивно, показ не отправляйте. Для частично видимых Snackbar, тултипа и карточек товаров используйте реальную долю из `VisibilityDetector`.

Не отправляйте engagement из `build()` или сразу после сетевого ответа. Переходы между шагами одного in-app не создают новое открытие.

SDK использует данные tracking из ответа: `variables.onImpression`, `onVisibleImpression`, `onClose` и `content.events`. Для `ContentClickEngagement` требуется событие типа `click`; товарные события используют исходный `Slot`. Если нужных данных нет, вызов engagement не создаст отправку. Проверяйте HTTP-запросы и отчёт кампании.

### Вызов из существующего экрана

Когда экран товара стал активен, вызовите функцию ниже один раз на фактический просмотр. Передайте общий контроллер, адаптер действий и проверку активности экрана из своего роутера:

```dart
Future<void> onProductScreenVisible({
  required BuildContext context,
  required String sku,
  required bool Function() isScreenCurrent,
  required InAppCoordinator inApp,
  required InAppActions actions,
}) => inApp.loadAndPresent(
  context: context,
  isScreenCurrent: isScreenCurrent,
  actions: actions,
  load: () => gf.GravitySDK.instance.trackViewNoShow(
    pageContext: gf.PageContext(
      type: gf.ContextType.product,
      data: [sku],
      location: 'app://product/$sku',
    ),
  ),
);
```

`isScreenCurrent` возвращает `true`, только когда исходный экран активен во всех родительских навигаторах. Для простого приложения с одним Navigator это `() => ModalRoute.of(context)?.isCurrent == true`. Контроллер проверяет её до открытия in-app; после открытия маршрут сообщения перекрывает исходный экран.

Для бизнес-события используйте тот же `loadAndPresent()`, заменив функцию `load`:

```dart
load: () => gf.GravitySDK.instance.triggerEventNoShow(
  events: [gf.AddToCartEvent(
    value: 1990, productId: sku, quantity: 1, currency: 'RUB',
  )],
  pageContext: gf.PageContext(
    type: gf.ContextType.product,
    data: [sku],
    location: 'app://product/$sku',
  ),
),
```

Это значение именованного аргумента. Вызывайте событийный путь после успешного изменения корзины. Не вызывайте второй `trackView()`/`triggerEvent()` для того же действия и не показывайте ответ ещё раз из `gravityContentCallback`.

В 0.25.0 этот путь работает без задержки кампании. После релиза с `delayTime` добавьте чтение задержки по опубликованному контракту и ожидание внутри занятого места перед `presentClientModal()`. После ожидания повторите `canOpen()`. При проверке общего возраста после паузы увеличьте допустимое время на настроенную задержку, сохраняя исходный `maxResponseAge` для самой загрузки. Иначе задержка дольше 10 секунд будет ошибочно отменять показ. В текущей версии поля задержки в результате NoShow нет.

## 8. Добавьте многошаговые кампании

Одна вариация содержит корневой контент со `step == null` и дополнительные элементы `contents` с номером в `step`. При `openStep`:

1. Отправьте клик по текущему шагу.
2. Найдите в `selected.variation.contents` элемент, у которого `step == onClick.step`.
3. Замените текущее содержимое найденным шагом, сохранив кампанию и состояние открытия.
4. Если шага нет, оставьте текущий экран и запишите ошибку в лог.

Не загружайте кампанию заново и не ищите шаг в другой вариации. Тултип не используется как дополнительный шаг. Сохраняйте флаги impression и visible impression на всё открытие, чтобы смена шага не создала повторный показ. Для клика и закрытия передавайте контент текущего шага. При расширении `InAppExposure` для нескольких шагов разделите состояние открытия и текущий контент: флаги показов сохраняются, а объект для кликов и закрытия меняется при переходе.

## 9. Добавьте опросы при необходимости

Если планируете собирать оценки и комментарии, поддержите формы в модальном окне, bottom sheet и оверлее. До реализации пропускайте кампании с элементами формы.

| Элемент | `element.type` | Данные |
|---|---|---|
| Выбор оценки | `optionSelect` | `options`, `attributeName`, `isRequired`, `style.rating` |
| Поле ввода | `textInput` | `attributeName`, `placeholder`, `minLength`, `maxLength`, `showCounter`, `isRequired` |

В 0.25.0 выбор оценки поддерживает один ответ из пяти значений от 1 до 5. Вариант ответа может сам содержать `onClick` с отправкой формы: сначала сохраните выбранное значение, затем обрабатывайте действие.

Храните ответы по `attributeName` в состоянии одного опроса. Пересчитывайте `visibleWhen` при каждом изменении: условие может проверять равенство, неравенство, числовые сравнения, пустое или непустое значение и группы «все»/«хотя бы одно». Числовую оценку храните числом до отправки, чтобы условия сравнения работали корректно.

### Отправка ответов

При `submitForm` выполните последовательность:

1. Соберите снимок ответов только видимых полей. Проверьте обязательность и длину. При положительном `minLength` пустой ввод тоже не проходит проверку; длину считайте по символам без пробелов по краям, учитывая составные символы и emoji. Ограничьте ввод через `maxLength`.
2. Если поля невалидны, покажите ошибки без отправки. Заблокируйте повторную отправку на время обработки.
3. Найдите событие типа `submitForm` в `content.events` и отправьте его URL через `triggerTrackingUrl()`. В исходном JSON тип называется `submit_form`. Ошибка tracking не должна отменять дальнейшее действие.
4. Передайте `CustomEvent` через `triggerEventNoShow()`. Тип и название берите из `onClick.event`. В `customProps` передайте ответы строками, а также `campaignId` и `experienceId` сохранённой вариации.
5. Выберите первое подходящее правило из `onClick.routes`; если ни одно не подошло, используйте `onClick.defaultRoute`. Выполните его действия по порядку: оставить форму, закрыть, открыть URL, диплинк или следующий шаг. После закрытия или ухода по ссылке не продолжайте действия над закрытым UI.

Скрытые поля не отправляйте. Видимое необязательное поле без ответа передавайте пустой строкой. Числовую оценку сериализуйте как `"4"`, а не `"4.0"`.

Пример данных события; `rating` и `feedback` здесь — имена полей конкретного опроса:

```json
{
  "type": "in-app-review-v1",
  "name": "In-app review submitted",
  "customProps": {
    "rating": "2",
    "feedback": "Не хватает истории заказов",
    "campaignId": "campaign-id",
    "experienceId": "experience-id"
  }
}
```

Контент, который вернётся при отправке ответов через `triggerEventNoShow()`, не используйте для нового показа. Если включён глобальный `gravityContentCallback`, он также не должен открыть второй in-app. Не меняйте для этого глобальный `isFetchContentOnTrack` на время запроса: настройка влияет и на конкурентные вызовы приложения.

Для опроса отправляйте `ContentCloseEngagement` один раз при любом закрытии, включая «Назад», жест и закрытие после отправки ответов. Сохраните единый флаг закрытия на всю сессию опроса. Возврат событийного метода не подтверждает доставку: учитывайте офлайн-очередь.

О настройке вопросов и данных ответа: [Опросы внутри приложения](/blog/2026-08-18-flutter-sdk-in-app-forms.md).

## 10. Согласуйте возможности приложения

Перед запуском передайте команде, которая настраивает кампании:

- экраны и события запуска с их `PageContext`;
- поддерживаемые форматы, элементы, действия и опросы;
- selector якорей тултипов и доступные диплинки;
- правила внешнего вида и ограничения дизайн-системы;
- сценарии блокировки и версию SDK, включая поддержку задержки.

Так кампании будут настраиваться с учётом возможностей текущей версии приложения.

## 11. Проверьте интеграцию

Подготовьте по тестовой кампании на каждый поддерживаемый формат с несколькими действиями. Приоритет и задержку проверяйте на тех же кампаниях, меняя настройки.

| Проверка | Ожидаемый результат |
|---|---|
| Открытие экрана с кампанией каждого формата | Нужный контейнер, элементы и стили; один in-app |
| Два кандидата с разным приоритетом | Выбрана кампания с большим приоритетом; при отсутствии её корневого контента — следующая |
| Задержка `0` и ненулевая задержка после обновления SDK | Пауза соответствует настройке; нет паузы внутри самого NoShow |
| Уход с экрана, смена SKU или аккаунта во время запроса и ожидания | Старый контент не показан, impression не отправлен |
| Оплата, авторизация, перекрывающий маршрут, фон приложения | Новый in-app не открыт; старый ответ не появляется после разблокировки |
| Два почти одновременных ответа и глобальный callback | Не открываются два окна |
| Каждое действие и повторное быстрое нажатие | Действие выполнено один раз, статистика соответствует типу действия |
| Явное закрытие, «Назад», свайп, фон, таймер | Обычные сообщения и опросы учитывают close по своим правилам |
| Переход на шаг, в том числе отсутствующий | Корректная замена; нет повторного показа; при ошибке остаётся текущий шаг |
| Опрос с условным полем и невалидным вводом | Скрытые поля исключены, проверка обязательности работает, повторной отправки нет |
| Пустой ответ, неподдерживаемая схема, ошибка, отсутствие сети | Основной экран работает, нет ложных показов |

В debug-сборке используйте `LogLevel.debug`. Сопоставьте фактические открытия, клики и закрытия с HTTP-запросами и [отчётом кампании](/personalization/Campaigns/app_campaigns/getting_started.md). Для тестов аналитики используйте кампанию с настроенным tracking; учитывайте время доставки очереди и обновления отчёта.

Дополнительные проверки: [Проверка интеграции Flutter SDK](./troubleshooting.md). Контракты методов: [Справочник API](./api_reference.md).
