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; ниже отдельно описано текущее и предстоящее поведение.
Что реализовать
Одна кампания проходит цепочку: просмотр или событие → выбор и загрузка контента → подготовка UI и ожидание → проверка актуальности → показ → действия и статистика. Отсутствие контента, неподдерживаемая схема или неактуальный экран завершают её без показа. Основной экран приложения продолжает работать.
1. Подключите SDK
Для начала нужны apiKey и section вашего проекта. Получите их у команды Gravity Field. Android release-сборке требуется разрешение android.permission.INTERNET. Платформенные настройки.
Добавьте зависимости и выполните flutter pub get:
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:
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():
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() вызывайте один раз при запуске. До первого запроса настройте пользователя; перед выходом и сменой аккаунта отменяйте собственные показы по правилам ниже.
При isFetchContentOnTrack: false методы NoShow отправляют просмотр или событие, но не загружают контент и возвращают null. Для собственного UI используйте trackViewNoShow() и triggerEventNoShow(). Обычные trackView() и triggerEvent() могут запустить встроенный показ SDK.
Для проверки нужен опубликованный контент, доступный для вашего контекста. Его подготовку можно передать команде Gravity Field: сообщите экран, событие, поддерживаемые форматы и тестовый SKU. Дальнейшие шаги выполняются в коде приложения.
2. Получите контент
При открытии экрана передайте просмотр. Вызывайте метод из логики жизненного цикла экрана, а не из build():
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,
);
После успешного бизнес-действия, например изменения корзины:
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 обработал ошибку. Возврат событийного метода сам по себе не подтверждает доставку события. Подробнее: офлайн-доставка.
Если при инициализации задан gravityContentCallback, он тоже получает подробный ответ. Выберите один путь показа: возвращённое значение или callback. Иначе один ответ может открыть два in-app. В этом гайде используется возвращённое значение.
Выбор по приоритету
SDK сортирует активированные кампании по priority от большего к меньшему; отсутствующий приоритет считается равным 0. Затем запрашивает контент по очереди до первой кампании с корневым контентом. Если кампания не прошла таргетинг или корневого контента нет, SDK проверяет следующую.
Приложению не нужно перебирать кампании и повторно вызывать getContentByCampaignId(). Если формат уже выбранной кампании не поддерживается вашим UI, пропустите показ. Согласуйте поддерживаемые форматы с командой, которая настраивает кампании.
Корневой контент и вариация
Контент находится по цепочке ContentResponse.data → Campaign.payload → CampaignVariation.contents. Для первого экрана выбирайте step == null. Сохраните исходные кампанию, вариацию и контент: они понадобятся для статистики, переходов между шагами и отправки формы.
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 вашей кампании.
{
"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 выбранной кампании в миллисекундах. Номер версии и формат ответа будут указаны в описании релиза.
После обновления приложение должно:
- Получить контент и задержку выбранной кампании.
- При значении больше
0подождать указанное время после получения ответа, например черезFuture.delayed. При0или отсутствии значения перейти к следующему шагу сразу. - Повторно проверить актуальность и разрешение показа.
- Открыть собственный UI.
SDK не будет делать паузу внутри NoShow. Код ожидания подключайте после обновления на версию с передачей задержки.
Актуальность экрана и пользователя
Непосредственно перед показом, в том числе после загрузки изображений и задержки, проверьте:
- экран существует (
mounted), активен и не перекрыт другим маршрутом, включая вложенныйNavigator; - пользователь не сменился: не было входа, выхода,
setUser(),resetUser(),restoreUserId()или замены пользователя; - контекст не изменился: например, на карточке открыт тот же SKU;
- ответ относится к актуальному запросу и не заменён более новым;
- приложение активно, показ разрешён и другой in-app не открыт.
mounted подтверждает только существование виджета. Для маршрутов используйте состояние своего роутера. Для контекста и пользователя сохраните снимок при начале запроса или используйте счётчик поколения. При уходе с экрана, смене пользователя или товара увеличивайте счётчик; ответ с прежним значением пропускайте. Пример защиты от запоздавшего ответа есть в BDUI-гайде.
В опубликованном SDK 0.25.0 staleContentTimeout проверяется после ответа на просмотр или событие. Приложение дополнительно проверяет пользователя, экран и общий возраст ответа после загрузки контента и перед показом. Для пропущенного показа не отправляйте impression и visible impression.
Блокировки и одновременные ответы
lockPresentation() управляет встроенным автопоказом SDK. Для собственного UI создайте общий контроллер приложения, который знает, разрешён ли показ и открыт ли in-app.
Например, блокируйте новый показ на время оплаты, авторизации или собственного диалога. Продолжайте отправлять просмотры и бизнес-события. Контент, пришедший во время блокировки, пропускайте; после снятия блокировки ждите нового просмотра или события.
Перед открытием UI проверяйте и занимайте место без промежуточного await, чтобы два одновременных ответа не открыли два окна. Освобождайте место при закрытии и ошибке через finally. Если блокирующие сценарии пересекаются, учитывайте каждый: завершение одного не должно снимать остальные блокировки.
Уже открытый in-app при включении блокировки закрывайте только по правилам своего сценария. Ожидающий показ отменяйте, если экран или пользователь стали неактуальны.
Подключите функции приложения
Навигацию и push-разрешения оставьте в существующих сервисах приложения. Опишите их контракт в интеграционном модуле:
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;
}
Создайте InAppActions с этими функциями один раз в слое интеграции. Это адаптеры к вашему приложению: SDK не предоставляет роутер или push-провайдер. Проверяйте адрес и результат открытия в соответствующем адаптере. При ошибке функция должна завершаться исключением, которое обработчик in-app запишет в лог.
Контроллер показа
Создайте один InAppCoordinator на приложение для всех собственных in-app. Он отправляет трекинг даже при блокировке UI, ограничивает возраст ответа через maxResponseAge (по умолчанию 10 секунд), пропускает устаревшие ответы и удерживает место до завершения маршрута сообщения.
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');
}
}
}
Подключите контроллер к жизненному циклу:
invalidate() отменяет ожидающие ответы и закрывает собственный маршрут без пользовательского close. block() запрещает новые показы, сохраняя уже открытый. Снятие блокировки не запускает старую кампанию.
В обработчике навигации различайте маршруты приложения и маршрут самого in-app с именем gravity-in-app. Его открытие не должно вызывать invalidate(), иначе контроллер сразу закроет своё сообщение. В приложении с вложенными Navigator проверяйте всю цепочку активных маршрутов.
4. Отрисуйте формат и контейнер
Формат определяется content.deliveryMethod. В таблицах ниже приведены имена значений Dart-моделей SDK: например, DeliveryMethod.bottomSheet соответствует bottom_sheet в исходном JSON.
Параметры внешнего блока находятся в content.variables.frameUI:
Для 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.
Товары рисуйте в порядке slots, атрибуты берите из slot.item согласно вашему фиду. Сохраняйте исходный Slot для статистики товара. Пустой товарный блок не отображайте, неизвестный элемент пропускайте. Если после этого нет пригодного содержимого, пропустите весь in-app.
Если рендерер ещё не поддерживает формы, пропускайте кампании с optionSelect, textInput или действием submitForm. Иначе пользователь увидит опрос, который не сможет заполнить или отправить.
Для стандартных полей используйте типизированные модели. content.rawVariables нужен для дополнительных ключей вашего приложения. Весь исходный JSON доступен в details.json; порядок работы с произвольными данными описан в общем headless-гайде.
Изображения и дизайн-система
Загружайте изображения по URL из element.src, frameUI.container.style.backgroundImage и атрибутов slot.item. По возможности подготовьте их до открытия окна, затем повторите проверки актуальности.
Размеры style.size применяйте в логических пикселях. При макете на всю ширину изображение занимает ширину контейнера; вписывание берите из style.fit, скругление — из style.cornerRadius.
Цвета, размеры, начертания и отступы из редактора применяйте к UI. Параметры, которые определяет приложение, согласуйте со своей дизайн-системой:
- шрифт и замену отсутствующих начертаний;
- межстрочный интервал и поведение при увеличенном системном шрифте;
- ограничение строк для коротких сообщений и прокрутку длинного содержимого;
- максимальную ширину окна на планшетах и высоту относительно экрана;
- анимации, светлую и тёмную темы.
Проверяйте, что текст и кнопка закрытия доступны на маленьком экране и при крупном шрифте. Настройки визуального редактора описаны в Кастомизации in-app.
Рендеринг modal
Следующий фрагмент добавьте в тот же модуль. Он рисует текст, кнопку, изображение и отступ. Читает nullable-поля стиля с fallback, фон контейнера и его отступы. Кнопка закрытия здесь использует крестик дизайн-системы в правом верхнем углу; максимальная ширина 560 — правило примера для планшета. Для точного воспроизведения редактора примените также frameUI.close.image, style.positioned и style.size кнопки закрытия.
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, многошаговых действий и клика по контейнеру:
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.
Сначала вызовите отправку статистики нажатия, затем выполните действие. Доставка статистики не должна блокировать навигацию. Защитите обработчик от повторного нажатия до завершения действия. Диплинк открывайте после закрытия 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, выберите его и согласуйте с ним проверки маршрутов. При закрытии удаляется именно маршрут сообщения, поэтому чужой диалог поверх него не будет закрыт случайно.
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() с исходными объектами ответа.
Чтобы учитывать закрытия обычных сообщений так же, как встроенный UI, не отправляйте close при системном «Назад», свайпе, нажатии на фон, истечении времени Snackbar или автоматическом закрытии после действия. Для опросов действует отдельное правило:
Для каждого открытия создавайте один InAppExposure вне build(). Он используется в presentClientModal() выше:
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-запросы и отчёт кампании.
Вызов из существующего экрана
Когда экран товара стал активен, вызовите функцию ниже один раз на фактический просмотр. Передайте общий контроллер, адаптер действий и проверку активности экрана из своего роутера:
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:
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:
- Отправьте клик по текущему шагу.
- Найдите в
selected.variation.contentsэлемент, у которогоstep == onClick.step. - Замените текущее содержимое найденным шагом, сохранив кампанию и состояние открытия.
- Если шага нет, оставьте текущий экран и запишите ошибку в лог.
Не загружайте кампанию заново и не ищите шаг в другой вариации. Тултип не используется как дополнительный шаг. Сохраняйте флаги impression и visible impression на всё открытие, чтобы смена шага не создала повторный показ. Для клика и закрытия передавайте контент текущего шага. При расширении InAppExposure для нескольких шагов разделите состояние открытия и текущий контент: флаги показов сохраняются, а объект для кликов и закрытия меняется при переходе.
9. Добавьте опросы при необходимости
Если планируете собирать оценки и комментарии, поддержите формы в модальном окне, bottom sheet и оверлее. До реализации пропускайте кампании с элементами формы.
В 0.25.0 выбор оценки поддерживает один ответ из пяти значений от 1 до 5. Вариант ответа может сам содержать onClick с отправкой формы: сначала сохраните выбранное значение, затем обрабатывайте действие.
Храните ответы по attributeName в состоянии одного опроса. Пересчитывайте visibleWhen при каждом изменении: условие может проверять равенство, неравенство, числовые сравнения, пустое или непустое значение и группы «все»/«хотя бы одно». Числовую оценку храните числом до отправки, чтобы условия сравнения работали корректно.
Отправка ответов
При submitForm выполните последовательность:
- Соберите снимок ответов только видимых полей. Проверьте обязательность и длину. При положительном
minLengthпустой ввод тоже не проходит проверку; длину считайте по символам без пробелов по краям, учитывая составные символы и emoji. Ограничьте ввод черезmaxLength. - Если поля невалидны, покажите ошибки без отправки. Заблокируйте повторную отправку на время обработки.
- Найдите событие типа
submitFormвcontent.eventsи отправьте его URL черезtriggerTrackingUrl(). В исходном JSON тип называетсяsubmit_form. Ошибка tracking не должна отменять дальнейшее действие. - Передайте
CustomEventчерезtriggerEventNoShow(). Тип и название берите изonClick.event. ВcustomPropsпередайте ответы строками, а такжеcampaignIdиexperienceIdсохранённой вариации. - Выберите первое подходящее правило из
onClick.routes; если ни одно не подошло, используйтеonClick.defaultRoute. Выполните его действия по порядку: оставить форму, закрыть, открыть URL, диплинк или следующий шаг. После закрытия или ухода по ссылке не продолжайте действия над закрытым UI.
Скрытые поля не отправляйте. Видимое необязательное поле без ответа передавайте пустой строкой. Числовую оценку сериализуйте как "4", а не "4.0".
Пример данных события; rating и feedback здесь — имена полей конкретного опроса:
{
"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 один раз при любом закрытии, включая «Назад», жест и закрытие после отправки ответов. Сохраните единый флаг закрытия на всю сессию опроса. Возврат событийного метода не подтверждает доставку: учитывайте офлайн-очередь.
О настройке вопросов и данных ответа: Опросы внутри приложения.
10. Согласуйте возможности приложения
Перед запуском передайте команде, которая настраивает кампании:
- экраны и события запуска с их
PageContext; - поддерживаемые форматы, элементы, действия и опросы;
- selector якорей тултипов и доступные диплинки;
- правила внешнего вида и ограничения дизайн-системы;
- сценарии блокировки и версию SDK, включая поддержку задержки.
Так кампании будут настраиваться с учётом возможностей текущей версии приложения.
11. Проверьте интеграцию
Подготовьте по тестовой кампании на каждый поддерживаемый формат с несколькими действиями. Приоритет и задержку проверяйте на тех же кампаниях, меняя настройки.
В debug-сборке используйте LogLevel.debug. Сопоставьте фактические открытия, клики и закрытия с HTTP-запросами и отчётом кампании. Для тестов аналитики используйте кампанию с настроенным tracking; учитывайте время доставки очереди и обновления отчёта.
Дополнительные проверки: Проверка интеграции Flutter SDK. Контракты методов: Справочник API.