Backend-Driven UI: кнопка товара
Backend-Driven UI позволяет менять параметры интерфейса через кампанию Gravity Field. Приложение заранее поддерживает конечный набор действий и проверяет серверную конфигурацию перед применением.
Пример рассчитан на gravity_sdk 0.24.1. На экране товара показываем либо «Подробнее», либо «Оценить товар». Кампания выбирает подходящую конфигурацию; приложение отображает кнопку и выполняет действие.
Контракт кампании
- Создайте Custom JSON/API кампанию с selector
product_button. - Настройте таргетинг для
PRODUCTи locationapp://product/sku-123; передавайте SKU текущего товара. - Настройте вариации или сценарии, например для новых и возвращающихся пользователей.
- Проверьте, что
content.variables['product_button']содержит объект схемы ниже. - Настройте показ, видимость и
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.