# Гайд: Headless-режим с Flutter SDK

Этот гайд предназначен для разработчиков, которые хотят использовать возможности персонализации Gravity Field, но полностью контролировать UI и UX в своём Flutter-приложении.

Headless-режим означает, что SDK выполняет только логику таргетинга и доставки данных, а отрисовка интерфейса и отправка событий взаимодействия (impressions, clicks) лежит на плечах приложения.


# 1. Концепции и поток данных

В Headless-режиме SDK выступает в роли "поставщика данных". Вы делаете запрос с контекстом, SDK возвращает JSON-пейлоад (например, цвет кнопки, текст баннера или список товаров), а ваше приложение рендерит это на экране.

Критически важно: Так как SDK не контролирует UI, оно не может автоматически отследить показ (Impression) или клик (Click). Вы обязаны вызывать методы трекинга вручную, иначе аналитика кампании будет пустой.

sequenceDiagram
    participant App as App
    participant SDK as SDK
    participant Server as Server

    Note over App: 1. Запрос контента
    App->>SDK: trackViewNoShow / triggerEventNoShow
    SDK->>Server: Request (Context + User ID)
    Server-->>SDK: Response (JSON + DecisionID)
    SDK-->>App: GravityDataResponse

    Note over App: 2. Рендеринг UI
    App->>App: Парсинг JSON и отображение виджетов

    Note over App: 3. Обязательный трекинг
    App->>SDK: sendContentEngagement(Impression)
    SDK->>Server: WRIMP (Показ засчитан)

    opt Пользователь нажал на элемент
        App->>SDK: sendContentEngagement(Click)
        SDK->>Server: CLICK (Клик засчитан)
    end

# 2. Контракты данных

# 2.1. Запрос: PageContext

Корректный контекст — залог того, что кампания сработает. Параметры type и location должны совпадать с настройками таргетинга в дашборде.

PageContext(
  type: ContextType.product, // Тип страницы (обязательно)
  data: ['sku-123'], // ID товара/категории (если применимо)
  location: 'app://product/123', // Уникальный ID экрана
)

# 2.2. Ответ: GravityDataResponse

Когда SDK возвращает данные, вы получаете объект GravityDataResponse. Вот где искать ваши данные:

Поле Тип Описание
custom dynamic Ваш JSON-пейлоад. Именно здесь лежат данные, которые вы задали в редакторе кампании (например, { "title": "Hello", "color": "#FF0000" }).
decisionId String Токен для аналитики. Критически важен для отправки событий Impression и Click. Без него кампания будет иметь 0 показов в отчётах.
payload CampaignPayload Содержит служебную информацию о типе кампании.
campaignId String ID кампании (для отладки).
variationId String ID вариации (для отладки).

# 3. GravityContentCallback

GravityContentCallback — это тип функции, используемый для получения данных из Headless-методов. Поскольку сетевые запросы асинхронны, SDK использует этот колбэк для передачи GravityDataResponse обратно в ваше приложение, когда данные готовы.

Сигнатура:

typedef GravityContentCallback = void Function(GravityDataResponse response);

Как использовать:

Вы можете определить колбэк как метод в вашем классе или передать его как анонимную функцию.

// Определение обработчика
void onContentReceived(GravityDataResponse response) {
  if (response.custom != null) {
    // Обновляем состояние UI здесь
    setState(() {
      _bannerData = response.custom;
    });
  }
}

// Передача в SDK
GravitySDK.instance.trackViewNoShow(
  context: context,
  pageContext: pageContext,
  onContent: onContentReceived, // Передаём ссылку на функцию
);

# Best Practices

  • State Management: GravityContentCallback вызывается вне цикла сборки (build cycle), поэтому обновления состояния (например, setState, bloc.add) должны обрабатываться аккуратно.
  • Обработка ошибок/пустых данных: Всегда проверяйте, содержит ли response.custom или response.payload валидные данные перед попыткой отрисовки.
  • Безопасность контекста: Если вы обновляете UI внутри колбэка, убедитесь, что виджет всё ещё смонтирован.

# 4. Реализация

# 4.1. Получение данных

Есть три основных метода для получения headless-данных. Выберите тот, который подходит под ваш сценарий.

# Вариант A: При просмотре экрана (trackViewNoShow)

Используйте, если кампания привязана к открытию экрана (например, баннер на главной).

// Define callback
GravityContentCallback onContent = (response) {
  _handleHeadlessResponse(response);
};

    await GravitySDK.instance.trackViewNoShow(
      context: context,
      pageContext: PageContext(
    type: ContextType.homepage,
        data: [],
    location: 'app://home',
  ),
  onContent: onContent,
);

# Вариант B: При событии (triggerEventNoShow)

Используйте, если кампания триггерится действием (например, добавление в корзину).

await GravitySDK.instance.triggerEventNoShow(
  context: context,
  events: [AddToCartEvent(...)],
  pageContext: PageContext(...),
  onContent: _handleHeadlessResponse, // Pass method reference
);

# Вариант C: Прямой запрос (getContentBySelectorWithDetails)

Используйте, если нужно загрузить конкретную кампанию по её API-селектору.

    final response = await GravitySDK.instance.getContentBySelectorWithDetails(
  selector: 'my_custom_banner',
  pageContext: PageContext(...),
);

if (response != null) {
  _handleHeadlessResponse(response);
}

# 4.2. Обработка ответа и парсинг JSON

Получив GravityDataResponse, извлеките данные и decisionId.

void _handleHeadlessResponse(GravityDataResponse response) {
  // 1. Сохраняем decisionId для аналитики
  final decisionId = response.decisionId;

  // 2. Извлекаем Custom JSON
  // Важно: response.custom может быть Map или String (в зависимости от версии SDK)
  Map<String, dynamic> data;
  if (response.custom is String) {
    data = jsonDecode(response.custom);
  } else {
    data = Map<String, dynamic>.from(response.custom);
  }

  // 3. Передаём в UI (например, через State или BLoC)
  setState(() {
    _bannerData = BannerModel.fromJson(data);
    _currentDecisionId = decisionId;
  });
}

# 4.3. Обязательный трекинг (Engagement)

Это самый важный шаг. Если вы отобразили контент, но не отправили событие, кампания будет считаться "несработавшей".

# Отправка Impression (Показ)

Вызывайте, когда виджет реально появился на экране (например, в initState или через VisibilityDetector).

if (_currentDecisionId != null) {
  GravitySDK.instance.sendContentEngagement(
    ContentImpressionEngagement.fromDecisionId(_currentDecisionId!)
  );
}

# Отправка Click (Клик)

Вызывайте в обработчике нажатия (например, onTap).

GestureDetector(
  onTap: () {
    if (_currentDecisionId != null) {
      GravitySDK.instance.sendContentEngagement(
        ContentClickEngagement.fromDecisionId(_currentDecisionId!)
      );
    }
    // ... ваша логика перехода
  },
  child: ...
)

# 5. Best Practices & Architecture

# 5.1. Разделение ответственности

Не смешивайте логику SDK с UI-кодом. Используйте промежуточный слой (Repository или Service), который:

  1. Вызывает SDK.
  2. Парсит GravityDataResponse в доменную модель.
  3. Возвращает пару (Model, DecisionId).

# 5.2. State Management

Headless-запросы асинхронны. Используйте BLoC, Riverpod или Provider для управления состояниями:

  • Loading: пока ждём ответ от SDK.
  • Content: данные пришли, отображаем UI.
  • Empty/Error: кампания не найдена или ошибка (показываем fallback или ничего).

# 6. Типичные ошибки (Troubleshooting)

Проблема Причина Решение
Ghost Impressions Кампания работает, но в отчётах 0 показов. Вы забыли вызвать sendContentEngagement(Impression).
Missing Engagement Высокие показы, но 0 кликов (CTR 0%). Не настроен вызов sendContentEngagement(Click) на onTap.
Context Mismatch Кампания не возвращается. PageContext в коде не совпадает с настройками таргетинга (например, type: home вместо cart).
Duplicate Events Слишком много показов. sendContentEngagement вызывается в методе build() или в цикле. Вынесите его в initState или используйте VisibilityDetector с флагом _impressionSent.