# Backend-Driven UI: кнопка товара

Backend-Driven UI позволяет менять параметры интерфейса через кампанию Gravity Field. Приложение заранее поддерживает конечный набор действий и проверяет серверную конфигурацию перед применением.

Пример рассчитан на `gravity_sdk 0.24.1`. На экране товара показываем либо «Подробнее», либо «Оценить товар». Кампания выбирает подходящую конфигурацию; приложение отображает кнопку и выполняет действие.

## Контракт кампании

1. Создайте Custom JSON/API кампанию с selector `product_button`.
2. Настройте таргетинг для `PRODUCT` и location `app://product/sku-123`; передавайте SKU текущего товара.
3. Настройте вариации или сценарии, например для новых и возвращающихся пользователей.
4. Проверьте, что `content.variables['product_button']` содержит объект схемы ниже.
5. Настройте показ, видимость и `click` в tracking ответа; опубликуйте кампанию.

Конфигурация «Подробнее»:

```json
{"type": "button", "label": "Подробнее", "action": "details"}
```

Конфигурация «Оценить»:

```json
{"type": "button", "label": "Оценить товар", "action": "rating"}
```

Контракт принадлежит приложению: SDK не валидирует его и не выполняет `details`/`rating`. Неподдерживаемое действие должно оставить fallback, а не запускать случайную бизнес-логику.

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

Сначала инициализируйте SDK по [быстрому старту](./ask_flutter.md). Добавьте `visibility_detector: ^0.4.0+2` и выполните `flutter pub get`. Скопируйте [gravity_content.dart](./examples/gravity_content.dart) и [bdui_example.dart](./examples/bdui_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';

enum ProductButtonAction { details, rating }

class ProductButtonConfig {
  const ProductButtonConfig({required this.label, required this.action});
  final String label;
  final ProductButtonAction action;

  static ProductButtonConfig? parse(Map<String, dynamic>? json) {
    if (json == null || json['type'] != 'button') return null;
    final label = json['label'];
    final action = switch (json['action']) {
      'details' => ProductButtonAction.details,
      'rating' => ProductButtonAction.rating,
      _ => null,
    };
    if (label is! String || label.trim().isEmpty || action == null) return null;
    return ProductButtonConfig(label: label, action: action);
  }
}

class DynamicProductButton extends StatefulWidget {
  const DynamicProductButton({super.key, required this.productId});
  final String productId;

  @override
  State<DynamicProductButton> createState() => _DynamicProductButtonState();
}

class _DynamicProductButtonState extends State<DynamicProductButton> {
  ProductButtonConfig? config;
  ContentExposure? exposure;
  Key visibilityKey = UniqueKey();
  int generation = 0;

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

  @override
  void didUpdateWidget(DynamicProductButton oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.productId != widget.productId) {
      config = null;
      exposure = null;
      visibilityKey = UniqueKey();
      _load();
    }
  }

  Future<void> _load() async {
    final request = ++generation;
    final productId = widget.productId;
    try {
      final selected = await loadSelectedContent(
        selector: 'product_button',
        pageContext: PageContext(
          type: ContextType.product,
          data: [productId],
          location: 'app://product/$productId',
        ),
      );
      if (!mounted || request != generation || selected == null) return;
      final parsed = ProductButtonConfig.parse(
        selected.content.variables.valueOf<Map<String, dynamic>>(
          'product_button',
        ),
      );
      if (parsed == null) return;
      setState(() {
        config = parsed;
        exposure = ContentExposure(selected);
        visibilityKey = UniqueKey();
      });
    } catch (_) {
      // Исходная кнопка «Подробнее» остаётся доступна.
    }
  }

  Future<void> _activate(ProductButtonAction action) async {
    exposure?.click();
    await showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(
          action == ProductButtonAction.rating ? 'Оцените товар' : 'О товаре',
        ),
        content: Text(widget.productId),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('Закрыть'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) => VisibilityDetector(
    key: visibilityKey,
    onVisibilityChanged: (info) => exposure?.onVisibility(info.visibleFraction),
    child: FilledButton(
      onPressed: () => _activate(config?.action ?? ProductButtonAction.details),
      child: Text(config?.label ?? 'Подробнее'),
    ),
  );
}
```

На экране товара добавьте:

```dart
import 'bdui_example.dart';

// В дереве экрана:
const DynamicProductButton(productId: 'sku-123')
```

Пример выполняет оба действия через диалог. Подключите нужные экраны/бизнес-логику приложения вместо этих демонстрационных диалогов. SKU берётся из текущего Flutter-виджета, а не из произвольной серверной команды.

## Жизненный цикл

Сетевой запрос начинается один раз при создании виджета. При изменении `productId` конфигурация очищается и выполняется новый запрос. Счётчик `generation` не даёт запоздалому ответу предыдущего товара заменить текущую кнопку; `mounted` защищает размонтированное состояние.

Пока данные загружаются, доступна кнопка «Подробнее». Пустой ответ, ошибка или неподдерживаемая схема сохраняют её. Показ кампании фиксируется только после применения корректной конфигурации и фактического появления кнопки. Новая конфигурация получает отдельный detector и отдельные флаги показов.

Смена пользователя без смены товара тоже должна пересоздать виджет с новым `Key` либо явно запустить обновление состояния в приложении.

## Проверка

- Действия `details` и `rating` открывают ожидаемые диалоги.
- Неизвестное действие, неверный `type`, отсутствующая или пустая надпись оставляют fallback.
- Ответ товара A не применяется после перехода к товару B.
- Показ вне видимой области не считается visible impression.
- Прокрутка и перестроение не дублируют показы; клики отправляются через исходные объекты SDK.
- Ошибка или отсутствие tracking-события не мешают действию кнопки.

Получение данных и правила аналитики описаны в [Headless и Custom JSON](./flutter_headless_guide.md). Для эксперимента с распределением трафика используйте [A/B через JSON](./flutter_ab_test_json_guide.md).
