# Headless и Custom JSON

Headless подходит, когда Gravity Field выбирает кампанию и доставляет данные, а приложение само строит UI. Вы получаете контент через SDK, не реализуя собственный HTTP-клиент.

Документация относится к **0.24.1**. Сначала выполните [инициализацию](./configuration.md). Настройка кампаний: [Кампании в приложении](/personalization/Campaigns/app_campaigns/getting_started.md).

## Выбор метода

| Метод | Когда использовать | Результат |
|---|---|---|
| `getContentBySelector()` | Загрузить размещение по selector | `ContentResponse` |
| `getContentByCampaignId()` / `getContentByGroup()` | Загрузить кампанию или группу | `ContentResponse` |
| `getContentBySelectorWithDetails()` / `getContentByCampaignIdWithDetails()` | Нужна модель и исходный ответ JSON | `GravityDataResponse<ContentResponse>` |
| `trackViewNoShow()` | Передать просмотр и получить сработавшую кампанию без показа | Nullable `GravityDataResponse<ContentResponse>` |
| `triggerEventNoShow()` | Передать действие и получить сработавшую кампанию без показа | Nullable `GravityDataResponse<ContentResponse>` |

`NoShow` принимает `pageContext`, а событийный метод ещё и `events`. Для этих методов `BuildContext` не требуется; ответ возвращается через `Future`.

`getContentBy*` не отправляет отдельный явный просмотр экрана; передавайте его согласно сценарию через методы трекинга. Избегайте двух независимых запросов одного placement без необходимости.

## Контракт ответа

```dart
final details = await GravitySDK.instance.getContentBySelectorWithDetails(
  selector: 'checkout_button',
  pageContext: const PageContext(
    type: ContextType.cart,
    data: [],
    location: 'app://cart',
  ),
);

final ContentResponse model = details.data;
final Map<String, dynamic> rawJson = details.json;
```

`GravityDataResponse<T>` содержит типизированную модель в `data` и исходный ответ сервера в `json`.

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

`CampaignVariation` содержит идентификаторы кампании/вариации и `decisionId`. Для SDK engagement не нужно извлекать эти ID и вручную собирать URL: используйте исходные объекты кампании и контента.

## Общий код для примеров

Создайте `lib/gravity_content.dart` из [этого файла](./examples/gravity_content.dart). Он используется также в A/B и BDUI. Загрузчик возвращает корневой контент; ошибки запроса обрабатывает вызывающий экран.

```dart
import 'package:gravity_sdk/gravity_sdk.dart';

class SelectedContent {
  const SelectedContent({required this.campaign, required this.content});
  final Campaign campaign;
  final CampaignContent content;
}

SelectedContent? selectRoot(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 SelectedContent(campaign: campaign, content: content);
        }
      }
    }
  }
  return null;
}

Future<SelectedContent?> loadSelectedContent({
  required String selector,
  required PageContext pageContext,
}) async {
  final response = await GravitySDK.instance.getContentBySelector(
    selector: selector,
    pageContext: pageContext,
  );
  return selectRoot(response);
}

// Один объект на одно фактическое размещение полученного контента.
// Загрузка не считается показом. Показ фиксируем после появления UI.
class ContentExposure {
  ContentExposure(this.selected);
  final SelectedContent selected;
  bool _shown = false;
  bool _visible = false;

  void onVisibility(double fraction) {
    if (fraction > 0 && !_shown) {
      _shown = true;
      GravitySDK.instance.sendContentEngagement(
        ContentImpressionEngagement(selected.content, selected.campaign),
      );
    }
    if (fraction >= 0.5 && !_visible) {
      _visible = true;
      GravitySDK.instance.sendContentEngagement(
        ContentVisibleImpressionEngagement(selected.content, selected.campaign),
      );
    }
  }

  void click() {
    GravitySDK.instance.sendContentEngagement(
      ContentClickEngagement(selected.content, selected.campaign),
    );
  }
}
```

`ContentExposure` — вспомогательный класс примера, а не часть SDK. Сохраняйте один экземпляр на одно фактически показанное размещение. Не создавайте его заново при каждом `build()`. При новом выборе контента создайте новый экземпляр и новый ключ detector.

## Произвольные переменные

Ожидаемый фрагмент ответа кампании:

```json
{
  "variables": {
    "checkout_button": {
      "variant": "B",
      "label": "Перейти к оплате"
    }
  }
}
```

Ключ `checkout_button` и структура объекта — контракт **вашего приложения и кампании**; SDK не создаёт этот ключ по имени selector автоматически. Проверьте фактический ответ и настройте данные вариаций в кабинете под тот же контракт.

После загрузки `SelectedContent selected`:

```dart
final content = selected.content;
final Map<String, dynamic> variables = content.rawVariables;
final Object? value = content.variables['checkout_button'];
final Map<String, dynamic>? config =
    content.variables.valueOf<Map<String, dynamic>>('checkout_button');
```

`rawVariables` содержит весь объект, включая типизированные поля. Он доступен и у обычного `getContentBySelector()`: использовать `WithDetails` только ради него не нужно.

`valueOf<T>()` проверяет тип, но не преобразует значение и не проверяет схему. JSON-массив — `List<dynamic>`; число удобно читать как `num`. Целое число не проходит проверку `double`. Отсутствующий ключ или неверный тип возвращает `null`.

Карта неизменяема на верхнем уровне. Не изменяйте вложенные объекты ответа: они могут использоваться и в `details.json`. Для собственной изменяемой модели сделайте копию. Данные `content.custom?.json`, если их возвращает ваша кампания, — отдельная строка JSON; не приписывайте их объекту `GravityDataResponse`.

## Просмотр и событие без показа

```dart
final details = await GravitySDK.instance.trackViewNoShow(
  pageContext: const PageContext(
    type: ContextType.homepage,
    data: [],
    location: 'app://homepage',
  ),
);

final selected = details == null ? null : selectRoot(details.data);
```

```dart
final details = await GravitySDK.instance.triggerEventNoShow(
  events: [
    AddToCartEvent(
      value: 1990,
      productId: 'sku-123',
      quantity: 1,
      currency: 'RUB',
    ),
  ],
  pageContext: const PageContext(
    type: ContextType.product,
    data: ['sku-123'],
    location: 'app://product/sku-123',
  ),
);
```

`null` означает, что контент не получен: нет подходящей кампании, загрузка отключена, ответ устарел, событие осталось в очереди или SDK обработал ошибку. Это не доказательство отсутствия кампании в проекте.

Для наблюдения за подробными ответами доступен глобальный callback:

```dart
await GravitySDK.instance.initialize(
  apiKey: 'YOUR_API_KEY',
  section: 'YOUR_SECTION_ID',
  gravityContentCallback: (GravityDataResponse<ContentResponse> response) {
    print(response.data.data.length);
  },
);
```

Он задаётся при инициализации, а не в каждом `NoShow`. Успешный подробный запрос одновременно возвращает ответ и вызывает callback. В примерах UI обрабатывается через возвращённое значение, чтобы не показать его дважды.

## Аналитика собственного UI

Загрузка контента не означает, что пользователь его увидел. SDK вызывает contentLoaded после получения контента; приложение фиксирует показ и взаимодействия отдельно.

| Событие | Момент |
|---|---|
| `ContentImpressionEngagement(content, campaign)` | Собственный UI действительно появился |
| `ContentVisibleImpressionEngagement(content, campaign)` | Не менее 50% размещения видно; один раз на показ |
| `ContentClickEngagement(content, campaign)` | Пользователь нажал на собственный контент |
| `ContentCloseEngagement(content, campaign)` | Пользователь закрыл собственное сообщение |
| `ProductVisibleImpressionEngagement(slot, content, campaign)` | Товар видим в полностью собственном списке |
| `ProductClickEngagement(slot, content, campaign)` | Пользователь нажал на товар |

Отправка идёт через `sendContentEngagement()` или `sendProductEngagement()` с исходными объектами ответа.

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

Не вызывайте engagement из `build()` или сразу после сетевой загрузки. В примерах `VisibilityDetector` сообщает о видимости, а флаги предотвращают повторные показы при прокрутке. Встроенный UI SDK дополнительно этим кодом оборачивать не нужно.

## Ошибки и жизненный цикл

- Для пустого ответа или неподдерживаемой схемы оставляйте рабочий fallback приложения.
- Для `getContentBy*` перехватывайте исключение; `NoShow` обычно возвращает `null` после обработки ошибки.
- После запроса проверяйте `mounted`, а при изменении товара/пользователя — ещё и актуальность запроса.
- Не начинайте загрузку на каждом `build()`. После смены пользователя пересоздайте персонализированный экран или сбросьте его состояние.
- Headless не включает постоянный кеш персонализированного UI и не гарантирует его обновление без нового запроса.

Защита автопоказа при смене пользователя и экрана в 0.24.1 относится к встроенному UI SDK. Она не отбрасывает за приложение ответы `getContentBy*`, `NoShow` или глобального callback: сохраните проверку актуальности и пересоздание собственного UI после смены аккаунта.

Готовые экраны: [A/B-кнопка](./flutter_ab_test_json_guide.md), [BDUI-кнопка товара](./flutter_bdui_guide.md).
