# Гайд: Прямая API-интеграция для Flutter

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

Взамен вы берёте на себя ответственность за управление HTTP-запросами, состоянием, хранением идентификаторов пользователя и, что особенно важно, за отправку событий о взаимодействии (engagement).

# 1. Введение

# Когда выбирать прямую API-интеграцию?

  • Полный контроль над UI: Вы хотите создавать уникальные виджеты и анимации, которые невозможно реализовать стандартными шаблонами SDK.
  • Сложная логика состояния: Ваше приложение использует продвинутые техники управления состоянием (BLoC, Riverpod), и вы хотите интегрировать данные от Gravity Field в существующую архитектуру.
  • Минимализм: Вы предпочитаете не добавлять SDK как зависимость и работать с API напрямую.

# 2. Подготовка к работе

# HTTP-клиент

Для выполнения HTTP-запросов мы рекомендуем использовать пакет dio. Он предоставляет удобный API для работы с Interceptors, таймаутами и обработкой ошибок.

Добавьте dio в ваш pubspec.yaml:

dependencies:
  dio: ^5.8.0+1 # Используйте актуальную версию

Все запросы к API Gravity Field должны содержать заголовок Authorization с вашим API-ключом. Базовый URL для всех запросов: https://evs-01.gravityfield.ai/v2.

Пример настройки клиента Dio:

import 'package:dio/dio.dart';

class ApiClient {
  final Dio dio;
  final String apiKey;
  final String sectionId;

  ApiClient({required this.apiKey, required this.sectionId})
      : dio = Dio(
          BaseOptions(
            baseUrl: 'https://evs-01.gravityfield.ai/v2',
            connectTimeout: const Duration(seconds: 10),
            receiveTimeout: const Duration(seconds: 20),
            headers: {
              'Content-Type': 'application/json',
            },
          ),
        ) {
    dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) {
          options.headers['Authorization'] = 'Bearer $apiKey';
          return handler.next(options);
        },
      ),
    );
  }

  // ... методы для вызова API
}

# 3. Полная схема взаимодействия

Весь цикл интеграции, от идентификации пользователя до отслеживания клика, выглядит следующим образом:

sequenceDiagram
    participant App as Flutter App
    participant API as Gravity API

    App->>API: 1. Отправка события (POST /v2/visit)
    Note right of App: Отправляем с `uid` и `ses` (если есть)
    API-->>App: 200 OK<br/>Ответ содержит `user` (с uid/ses) и `campaigns` (с campaignId)
    Note right of App: Сохраняем `uid` на устройстве, `ses` в памяти

    alt Если есть campaignId
        App->>API: 2. Запрос контента (POST /v2/choose)
        API-->>App: 200 OK<br/>Ответ содержит JSON контента и массив `events` с URL для отслеживания
        Note right of App: Приложение рендерит UI из JSON

        App->>API: 3. Отправка события показа (GET на URL из `events` с type="impression")
        API-->>App: 204 No Content (Показ засчитан)

        User->>App: Пользователь кликает по элементу
        App->>API: 4. Отправка события клика (GET на URL из `events` с type="click")
        API-->>App: 204 No Content (Клик засчитан)
    end

# 4. Шаг 1: Отправка данных (контекст и события)

Первый шаг — сообщить Gravity Field о действиях пользователя (просмотр экрана, покупка и т.д.) и получить в ответ uid, ses и список ID активных кампаний.

# Управление идентификаторами uid и ses

  • uid: Уникальный идентификатор пользователя. Его необходимо сохранять на устройстве (например, в SharedPreferences) и использовать между сессиями.
  • ses: Идентификатор текущей сессии. Его нужно хранить в памяти на время работы приложения. При перезапуске приложения он должен быть null, чтобы сервер сгенерировал новый.

# Отслеживание контекста экрана (вызов /visit)

Эндпоинт /visit используется для отслеживания просмотров экранов (screenview). Это основной способ сообщить платформе, где находится пользователь, и является триггером для запуска кампаний, привязанных к контексту экрана.

# Объект PageContext

Ключевым объектом в запросе является ctx (PageContext), который описывает текущий экран.

Поле Тип Описание
type ContextType Обязательно. Тип экрана (например, PRODUCT, CATEGORY).
data List<String> Обязательно. Контекстные данные. Например, SKU для PRODUCT или название категории для CATEGORY.
location String Обязательно. Уникальный идентификатор местоположения (URL, deeplink, название экрана).
lng String? Язык или регион (например, ru, en).
attributes Map<String, Object> Дополнительные атрибуты для таргетинга (например, { 'loyalty_level': 'gold' }).

# Типы контекста ContextType

ContextType Описание Пример для поля data
HOMEPAGE Главный экран [] (пустой массив)
PRODUCT Карточка товара ['sku-12345'] (массив с одним SKU)
CART Экран корзины ['sku-123', 'sku-456'] (массив SKU всех товаров в корзине)
CATEGORY Экран категории ['Электроника', 'Смартфоны'] (иерархия категорий)
SEARCH Экран результатов поиска ['красные носки'] (поисковый запрос)
OTHER Любой другой экран [] (пустой массив)

# Пример кода для вызова /visit

Future<Map<String, dynamic>> trackVisit(String? uid, String? ses) async {
  final data = {
    'sec': sectionId,
    'device': {
      'userAgent': 'YourApp/1.0.0 (Dart; Flutter)',
    },
    'type': 'screenview',
    'user': {
      'uid': uid,
      'ses': ses,
    },
    // Пример PageContext для экрана продукта
    'ctx': {
      'type': 'PRODUCT',
      'data': ['product-sku-123'],
      'location': 'app://product/123',
      'attributes': {
        'is_premium_user': true,
      }
    },
    'options': {},
  };

  final response = await dio.post('/visit', data: data);
  // ... обработка ответа ...
  return response.data;
}

# Отслеживание действий пользователя (вызов /event)

Эндпоинт /event используется для отслеживания ключевых бизнес-событий (покупка, добавление в корзину, логин) или любых других кастомных действий. Эти события могут служить триггером для кампаний или использоваться в аналитике и сегментации.

# Стандартные типы событий

Платформа предоставляет набор стандартных событий с предопределенной структурой.

Тип события (type) Класс в SDK Ключевые поля Описание
purchase-v1 PurchaseEvent uniqueTransactionId, value, cart Отслеживание успешной покупки.
add-to-cart-v1 AddToCartEvent productId, quantity, value Добавление товара в корзину.
remove-from-cart-v1 RemoveFromCartEvent productId, quantity, value Удаление товара из корзины.
login-v1 LoginEvent cuid, cuidType Вход пользователя в систему.

# Кастомные события (CustomEvent)

Для отслеживания любых других действий используйте CustomEvent.

  • type: Уникальный системный тип события (например, survey-completed-v1).
  • name: Человекочитаемое имя (например, «Опрос пройден»).
  • properties: Дополнительные параметры в формате Map<String, String>.

# Пример кода для вызова /event

В теле запроса /event передается массив data, содержащий один или несколько объектов событий.

Future<Map<String, dynamic>> trackPurchaseEvent(String? uid, String? ses) async {
  final purchaseEvent = {
    'type': 'purchase-v1',
    'name': 'Purchase',
    'uniqueTransactionId': 'ORDER-12345',
    'value': 2550.75,
    'currency': 'RUB',
    'cart': [
      {'productId': 'sku-123', 'quantity': 1, 'itemPrice': 1000.50},
      {'productId': 'sku-456', 'quantity': 2, 'itemPrice': 775.125},
    ],
  };

  final data = {
    'sec': sectionId,
    'device': { /* ... */ },
    'user': { 'uid': uid, 'ses': ses },
    'ctx': {
      'type': 'OTHER',
      'data': ['checkout_success'],
      'location': 'app://checkout/success',
    },
    'data': [
      purchaseEvent // Массив с одним событием покупки
    ],
    'options': {},
  };

  final response = await dio.post('/event', data: data);
  // ... обработка ответа ...
  return response.data;
}

# 5. Шаг 2: Получение и рендеринг контента кампании (вызов /choose)

Если на предыдущем шаге вы получили campaignId, запросите контент этой кампании с помощью эндпоинта /choose.

# Общая структура ответа

Ответ /choose имеет сложную иерархическую структуру. Ключевые данные для рендеринга находятся по следующему пути: data[0].payload[0].contents[0].

  • data: Массив, соответствующий запрошенным кампаниям.
  • payload: Массив вариаций для кампании (в A/B тесте их может быть несколько).
  • contents: Массив контентных блоков внутри вариации.
  • decisionId: Уникальный ID, который необходимо использовать для отслеживания взаимодействий.

# Структура контента (CampaignContent)

Объект contents[0] содержит все необходимое для рендеринга.

Поле Тип Описание
contentType String Тип контента. json для кастомного UI, products для товарных рекомендаций.
variables Object Объект с UI-элементами (elements) и стилями.
products Object Объект с товарными рекомендациями (slots).
events List Критически важно! Массив с URL для отслеживания взаимодействий.

# Структура variables и elements

Если contentType равен json, основной контент для рендеринга находится в variables.elements. Это массив объектов, описывающих UI-элементы.

Тип элемента (type) Свойства Описание
image src, style, onClick Изображение.
text text, style Текстовый блок.
button text, style, onClick Кнопка.
products-container style Контейнер для отображения товарных рекомендаций из поля products.

Свойство onClick содержит action (например, follow_url) и url или deeplink для выполнения действия.

# Структура products и slots

Если contentType равен products (или в elements есть products-container), данные о товарах находятся в products.slots.

  • slots: Массив объектов, каждый из которых представляет товар.
  • slot.item: Объект с данными о товаре из вашего продуктового фида (sku, name, price, imageUrl и т.д.).
  • slot.slotId: Уникальный ID товара в рамках данной выдачи. Используется для отслеживания кликов по конкретному товару.
// Концептуальный пример рендеринга
// ...
final content = snapshot.data!['data'][0]['payload'][0]['contents'][0];
final elements = content['variables']['elements'] as List;
final products = content['products'];

return Column(
  children: elements.map((element) {
    switch (element['type']) {
      case 'text':
        return Text(element['text']);
      case 'button':
        return ElevatedButton(onPressed: () { /* ... */ }, child: Text(element['text']));
      case 'products-container':
        return buildProductCarousel(products); // Ваша функция для рендеринга карусели
      default:
        return SizedBox.shrink();
    }
  }).toList(),
);

# 6. Шаг 3: Отслеживание взаимодействий (Engagement)

Отслеживание взаимодействий — критически важный шаг для аналитики и A/B-тестов. Без отправки этих событий платформа не сможет измерить эффективность кампаний. Для этого используются URL из массива events в ответе /choose.

# Механизм отслеживания

В отличие от Server-to-Server API, мобильный API (v2) использует более простой и производительный механизм. Ответ на запрос /choose для каждой кампании содержит массив events. Каждый элемент этого массива — это объект с типом события и готовыми URL для его отслеживания.

Чтобы зафиксировать взаимодействие, вашему приложению достаточно отправить простой GET-запрос на соответствующий URL.

# Структура массива events

Массив events находится внутри каждого объекта contents в ответе /choose. Он может выглядеть так:

// Фрагмент ответа /choose
// ...
"contents": [
  {
    "contentId": "...",
    // ... другие поля контента
    "events": [
      {
        "type": "impression",
        "urls": [
          "https://evs-01.gravityfield.ai/engagement?type=IMP&decisionId=..."
        ]
      },
      {
        "type": "visible_impression",
        "urls": [
          "https://evs-01.gravityfield.ai/engagement?type=WRIMP&decisionId=..."
        ]
      },
      {
        "type": "click",
        "urls": [
          "https://evs-01.gravityfield.ai/engagement?type=CLICK&decisionId=..."
        ]
      }
    ]
  }
]
// ...

Для товарных рекомендаций (products.slots) структура аналогична, но events находятся внутри каждого slot.

# Схема взаимодействия

sequenceDiagram
    participant App as Flutter App
    participant API as Gravity API

    App->>API: 1. Запрос контента (POST /v2/choose)
    API-->>App: 200 OK<br/>Ответ содержит JSON контента и массив `events` с URL для отслеживания
    Note right of App: Приложение рендерит UI из JSON

    App->>API: 2. Отправка события показа (GET на URL из `events` с type="impression")
    API-->>App: 204 No Content (Показ засчитан)

    User->>App: Пользователь кликает по элементу
    App->>API: 3. Отправка события клика (GET на URL из `events` с type="click")
    API-->>App: 204 No Content (Клик засчитан)

# Пример кода для извлечения и вызова URL

Эта функция поможет найти нужный URL в массиве events и отправить по нему GET-запрос.

// Функция для отправки GET-запроса по URL
Future<void> trackEngagementUrl(String url) async {
  try {
    // Используем отдельный экземпляр Dio без базового URL и interceptors
    await Dio().get(url);
    print('Engagement sent: $url');
  } catch (e) {
    print('Failed to trigger engagement event for $url: $e');
  }
}

// Функция для поиска URL и его вызова
void processEngagement({
  required List<dynamic> events,
  required String eventType,
}) {
  final event = events.firstWhere(
    (e) => e['type'] == eventType,
    orElse: () => null,
  );

  if (event != null && event['urls'] is List && (event['urls'] as List).isNotEmpty) {
    String url = (event['urls'] as List).first;
    trackEngagementUrl(url);
  }
}

// --- Пример использования ---

// 1. Отправка показа всего виджета (после рендеринга)
// final contentEvents = chooseResponse['data'][0]['payload'][0]['contents'][0]['events'];
// processEngagement(events: contentEvents, eventType: 'impression');

// 2. Отправка клика по конкретному товару (в onTap)
// final slot = products['slots'][index];
// final slotEvents = slot['events'];
// processEngagement(events: slotEvents, eventType: 'click');

# Когда какие события вызывать

# Сценарий 1: Кампания без товаров (например, баннер)

  • impression: Сразу после рендеринга контента (используйте events из contents[0]).
  • visible_impression: Когда баннер впервые становится видимым во вьюпорте (используйте events из contents[0]).
  • click: В обработчике onTap/onPressed для интерактивного элемента (используйте events из contents[0]).

# Сценарий 2: Кампания с товарными рекомендациями

  • impression (виджет): Сразу после рендеринга всего виджета (используйте events из contents[0]).
  • visible_impression (виджет): Когда весь виджет становится видимым (используйте events из contents[0]).
  • visible_impression (товар): Когда конкретный товар (slot) становится видимым при скролле (используйте events из slot).
  • click (товар): При нажатии на конкретный товар (используйте events из slot).

# 7. FAQ и лучшие практики

В: Как обрабатывать ошибки API?

О: Всегда оборачивайте вызовы API в try-catch. В случае ошибки (например, нет сети или сервер вернул 500), показывайте пользователю UI по умолчанию (fallback). Не позволяйте ошибкам API нарушать работу вашего приложения.

В: Какие таймауты использовать?

О: Рекомендуется устанавливать таймауты на соединение (5–10 секунд) и получение ответа (15–20 секунд), чтобы не заставлять пользователя ждать слишком долго.

В: Что делать, если API не вернул кампанию?

О: Это нормальная ситуация. Если массив campaigns в ответе /visit пуст, или /choose возвращает пустой data, это означает, что для данного пользователя сейчас нет активных кампаний. В этом случае также показывайте UI по умолчанию.

# Спецификация объектов ответа API

Данная спецификация актуальна для Flutter SDK версии 0.9.8. Структура ответа может изменяться в будущих версиях.

Этот раздел описывает структуру JSON-объектов, которые возвращают эндпоинты API Gravity Field. Основным источником для этой спецификации служат модели данных Flutter SDK.

# Ответ эндпоинта /choose

Эндпоинт /choose возвращает наиболее сложную структуру, содержащую все данные, необходимые для отображения кампании. Корневым объектом является ContentResponse.

# ContentResponse (Корневой объект)

  • user (Object): Объект с идентификаторами пользователя. См. спецификацию объекта User.
  • data (List<Object>): Список объектов кампаний. См. спецификацию объекта Campaign.

# Campaign

  • selector (String, nullable): Селектор, по которому была запрошена кампания (если применимо).
  • payload (List<Object>): Список вариаций кампании. Обычно содержит один элемент. См. спецификацию объекта CampaignVariation.

# CampaignVariation

  • campaignId (String): Уникальный идентификатор кампании.
  • experienceId (String): Идентификатор сценария (experience).
  • variationId (String): Идентификатор вариации.
  • decisionId (String): Уникальный идентификатор решения о показе. Используется для отслеживания взаимодействий.
  • contents (List<Object>): Список контентных блоков. Обычно содержит один элемент. См. спецификацию объекта CampaignContent.

# CampaignContent

  • contentId (String): Уникальный идентификатор блока контента.
  • templateSystemName (String, enum, nullable): Системное имя шаблона (например, snackbar-1, snackbar-2).
  • deliveryMethod (String, enum): Способ отображения. Возможные значения:
    • modal: Модальное окно.
    • snackbar: Уведомление внизу экрана.
    • bottom_sheet: Шторка снизу.
    • fullscreen: Полноэкранный режим.
    • inline: Встраиваемый в верстку контент.
  • contentType (String): Тип контента (например, json, products, banner).
  • variables (Object): Объект, содержащий элементы UI и их стили. См. спецификацию объекта Variables.
  • products (Object, nullable): Объект с товарными рекомендациями. См. спецификацию объекта Products.
  • events (List<Object>, nullable): Список объектов с URL для отслеживания взаимодействий. См. спецификацию объекта Event для контента.

# Variables

Объект, описывающий UI кампании.

  • frameUI (Object, nullable): Стили и элементы рамки (контейнера) кампании. См. спецификацию объекта FrameUI.
  • elements (List<Object>): Список UI-элементов внутри кампании. См. спецификацию объекта Element.
  • onLoad (Object, nullable): Действие, которое нужно отследить при загрузке контента.
  • onImpression (Object, nullable): Действие при показе.
  • onVisibleImpression (Object, nullable): Действие при попадании в зону видимости.
  • onClose (Object, nullable): Действие при закрытии.

# FrameUI

  • container (Object): Стили основного контейнера.
    • style (Object): См. спецификацию объекта Style.
  • close (Object, nullable): Описание кнопки закрытия.
    • image (String, nullable): URL изображения для иконки закрытия.
    • onClick (Object, nullable): Действие при клике. См. спецификацию объекта OnClick.
    • style (Object): Стили для кнопки закрытия. См. спецификацию объекта Style.

# Element

Описывает один UI-элемент (текст, кнопка, изображение).

  • type (String, enum): Тип элемента. Возможные значения:
    • image
    • text
    • button
    • spacer (пустое пространство)
    • products-container (контейнер для товарных рекомендаций)
  • text (String, nullable): Текст для элементов text и button.
  • src (String, nullable): URL для элемента image.
  • style (Object, nullable): Стили элемента. См. спецификацию объекта Style.
  • onClick (Object, nullable): Действие при клике. См. спецификацию объекта OnClick.

# Style

Объект со стилями, аналогичными CSS. Поля являются опциональными.

  • backgroundColor (String, nullable): Цвет фона (например, #FFFFFF).
  • pressColor (String, nullable): Цвет при нажатии.
  • outlineColor (String, nullable): Цвет обводки.
  • cornerRadius (Double, nullable): Радиус скругления углов.
  • fontSize (Double, nullable): Размер шрифта.
  • fontWeight (String, nullable): Насыщенность шрифта (например, 400, 700).
  • textColor (String, nullable): Цвет текста.
  • fit (String, enum, nullable): Режим масштабирования для изображений (cover, contain и т.д.).
  • contentAlignment (String, enum, nullable): Выравнивание контента (start, center, end).
  • size (Object, nullable): Размеры элемента.
    • width (Double, nullable)
    • height (Double, nullable)
  • margin / padding (Object, nullable): Внешние/внутренние отступы.
    • left, right, top, bottom (Double)
  • positioned (Object, nullable): Абсолютное позиционирование.
    • left, right, top, bottom (Double, nullable)

# OnClick

Описывает действие, которое происходит при нажатии на элемент.

  • action (String, enum): Тип действия. Возможные значения:
    • follow_url: Переход по внешней ссылке.
    • follow_deeplink: Переход по диплинку.
    • copy: Копирование данных в буфер обмена.
    • close: Закрытие кампании.
    • request_push: Запрос разрешения на push-уведомления.
    • request_tracking: Запрос разрешения на отслеживание (ATT).
  • url (String, nullable): URL для действия follow_url.
  • deeplink (String, nullable): Диплинк для действия follow_deeplink.
  • copyData (String, nullable): Данные для копирования для действия copy.
  • closeOnClick (Boolean): Закрывать ли кампанию после выполнения действия. По умолчанию true.

# Products

Объект, содержащий товарные рекомендации.

  • strategyId (String): ID использованной рекомендательной стратегии.
  • name (String): Название стратегии.
  • fallback (Boolean): true, если была использована резервная (fallback) стратегия.
  • slots (List<Object>): Список слотов с товарами. См. спецификацию объекта Slot.

# Slot

Один слот с рекомендованным товаром.

  • item (Object): Объект с данными о товаре. См. спецификацию объекта Item.
  • fallback (Boolean): true, если товар был добавлен из резервной стратегии.
  • strId (Int): ID алгоритма внутри стратегии.
  • slotId (String): Уникальный ID слота для отслеживания взаимодействий.
  • events (List<Object>, nullable): Список URL для отслеживания взаимодействий с этим товаром. См. спецификацию объекта Event для продукта.

# Item

Данные о товаре, как они представлены в вашем продуктовом фиде. Набор полей может отличаться.

  • sku (String): Уникальный идентификатор товара.
  • groupId (String, nullable): Идентификатор группы товаров (например, одна модель в разных цветах).
  • name (String): Название товара.
  • price (String): Цена.
  • url (String): URL страницы товара.
  • imageUrl (String, nullable): URL основного изображения.
  • oldPrice (String, nullable): Старая цена (для скидок).
  • brand (String, nullable): Бренд.
  • inStock (Boolean, nullable): Наличие.
  • categories (List<String>, nullable): Список категорий.
  • keywords (List<String>, nullable): Ключевые слова.

# Event для контента

  • type (String, enum): Тип события (impression, visible_impression, close, click и т.д.). Соответствует Action.
  • urls (List<String>): Список URL, на которые нужно отправить GET-запрос для отслеживания события.

# Event для продукта

  • type (String, enum): Тип события (impression, visible_impression, click).
  • urls (List<String>): Список URL для отслеживания.

# Ответ эндпоинтов /visit и /event

Эти эндпоинты возвращают более простую структуру CampaignIdsResponse, содержащую ID кампаний, которые нужно запросить через /choose.

# CampaignIdsResponse (Корневой объект)

  • user (Object): Объект с идентификаторами пользователя. См. спецификацию объекта User.
  • campaigns (List<Object>): Список ID кампаний для активации. См. спецификацию объекта CampaignId.

# CampaignId

  • campaignId (String): ID кампании, которую нужно запросить через эндпоинт /choose.
  • trigger (String): Тип триггера, который активировал кампанию.

# Общие объекты

# User

  • uid (String, nullable): Уникальный ID пользователя, присвоенный Gravity Field.
  • custom (String, nullable): Ваш внутренний ID пользователя.
  • ses (String, nullable): ID текущей сессии.
  • attributes (Map<String, String>, nullable): Дополнительные атрибуты пользователя.