# A/B-тестирование интерфейса через JSON

В этом примере Gravity Field выбирает текст кнопки оформления заказа, а Flutter-приложение рисует её своим виджетом. Вариант назначает сервер; приложение не выбирает A/B локально.

Требуется `gravity_sdk 0.24.0` и выполненная [инициализация](./ask_flutter.md). Общий контракт получения данных и аналитики — в [headless-гайде](./flutter_headless_guide.md).

## Настройка кампании

1. Создайте кампанию с выдачей Custom JSON/API и selector `checkout_button`.
2. Настройте аудиторию и контекст корзины: `CART`, location `app://cart`.
3. Создайте две вариации и распределение трафика для A/B-теста.
4. Настройте данные так, чтобы в ответе `content.variables['checkout_button']` возвращался объект одной из схем ниже.
5. Настройте tracking показа, видимости и клика; для ручного клика в ответе требуется `events` с типом `click`.
6. Опубликуйте кампанию и проверьте фактический ответ.

Вариация A:

```json
{"variant": "A", "label": "Оформить заказ"}
```

Вариация B:

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

Это содержимое ключа `checkout_button`, а не полный ответ SDK. SDK не навязывает формат пользовательских переменных.

## Подключение во Flutter

Добавьте `visibility_detector: ^0.4.0+2` в `pubspec.yaml` и выполните `flutter pub get`. Скопируйте [gravity_content.dart](./examples/gravity_content.dart) и [ab_example.dart](./examples/ab_example.dart) рядом в `lib/`.

```dart
import 'package:flutter/material.dart';
import 'package:gravity_sdk/gravity_sdk.dart';
import 'package:visibility_detector/visibility_detector.dart';

import 'gravity_content.dart';

class CheckoutVariantButton extends StatefulWidget {
  const CheckoutVariantButton({super.key, required this.onCheckout});
  final VoidCallback onCheckout;

  @override
  State<CheckoutVariantButton> createState() => _CheckoutVariantButtonState();
}

class _CheckoutVariantButtonState extends State<CheckoutVariantButton> {
  ContentExposure? exposure;
  String label = 'Оформить заказ';
  Key visibilityKey = UniqueKey();

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    try {
      final selected = await loadSelectedContent(
        selector: 'checkout_button',
        pageContext: const PageContext(
          type: ContextType.cart,
          data: [],
          location: 'app://cart',
        ),
      );
      if (!mounted || selected == null) return;
      final config = selected.content.variables.valueOf<Map<String, dynamic>>(
        'checkout_button',
      );
      if (config == null) return;
      final variant = config['variant'];
      final text = config['label'];
      if ((variant != 'A' && variant != 'B') ||
          text is! String ||
          text.trim().isEmpty) {
        return;
      }
      setState(() {
        label = text;
        exposure = ContentExposure(selected);
        visibilityKey = UniqueKey();
      });
    } catch (_) {
      // Кнопка оформления остаётся доступна с исходным текстом.
    }
  }

  @override
  Widget build(BuildContext context) => VisibilityDetector(
    key: visibilityKey,
    onVisibilityChanged: (info) => exposure?.onVisibility(info.visibleFraction),
    child: FilledButton(
      onPressed: () {
        exposure?.click();
        widget.onCheckout();
      },
      child: Text(label),
    ),
  );
}
```

Подключите к экрану корзины:

```dart
import 'ab_example.dart';

// В дереве экрана корзины:
CheckoutVariantButton(
  onCheckout: () {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (_) => const Scaffold(
          body: Center(child: Text('Оформление заказа')),
        ),
      ),
    );
  },
)
```

В вашем приложении замените демонстрационный маршрут на экран оформления. Отправляйте `PurchaseEvent` после успешной покупки, а не при нажатии на кнопку. [Пример покупки](./events.md#покупка).

## Как учитываются показы и клики

До ответа показывается обычная рабочая кнопка. Если пришла поддерживаемая конфигурация, приложение применяет вариант, создаёт `ContentExposure` и новый ключ detector. Показ фиксируется только после появления выбранной кнопки в UI.

При прокрутке и перестроении один экземпляр `ContentExposure` не отправляет повторные impression/visible impression. Каждый реальный клик учитывается отдельно. Fallback без принятой конфигурации не считается показом серверного варианта.

`onCheckout` не блокируется сетевым запросом и работает даже при ошибке или пустом ответе. При смене пользователя пересоздайте виджет с новым `Key`; не оставляйте выбранный вариант предыдущего аккаунта.

## Проверка эксперимента

- Обе схемы возвращаются корректно, надпись соответствует принятому ответу.
- Пустой ответ, неверный тип, неизвестный вариант или пустая надпись оставляют исходную кнопку.
- Уход с экрана до ответа не вызывает обновление размонтированного состояния.
- Повторное `build()` не вызывает новую загрузку и не дублирует показ.
- В отчёте появляются tracking выбранной кампании и бизнес-конверсия после реальной покупки.
- Между запросами одного пользователя не сбрасываются UID и сессия ради получения другого варианта.

Распределение и атрибуция определяются настройками эксперимента на сервере; SDK не обещает постоянный вариант при произвольной смене идентификации или конфигурации кампании.
