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 ответа; опубликуйте кампанию.

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

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

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

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

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

Реализация

Сначала инициализируйте SDK по быстрому старту. Добавьте visibility_detector: ^0.4.0+2 и выполните flutter pub get. Скопируйте gravity_content.dart и bdui_example.dart в lib/.

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 ?? 'Подробнее'),
    ),
  );
}

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

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. Для эксперимента с распределением трафика используйте A/B через JSON.