diff --git a/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/.openspec.yaml b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/.openspec.yaml new file mode 100644 index 0000000000..2e24cfa4fa --- /dev/null +++ b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-07 diff --git a/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/README.md b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/README.md new file mode 100644 index 0000000000..94f259c88c --- /dev/null +++ b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/README.md @@ -0,0 +1,3 @@ +# add-homeds-upsell-bubble + +Анимированный бабл апсейла на карточке устройства (вертикаль homeds) diff --git a/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/design.md b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/design.md new file mode 100644 index 0000000000..7975f8d1b9 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/design.md @@ -0,0 +1,88 @@ +## Context + +Экран «Мой дом» показывает сетку карточек устройств; продукт хочет на части карточек показывать анимированный бабл апсейла (см. Figma: `node-id=22472-261837`, прототип `node-id=21830-537741`). Сам экран, сетка и бизнес-логика статусов устройства живут в отдельном consumer-приложении, которого нет в этом монорепо — здесь мы поставляем только сам компонент. + +Ближайший существующий по механике компонент — `Tooltip`/`Popover` (`sdds-core/uikit-compose`, реализация в `internal/popover/BasePopover.kt`): умеет автозакрытие по таймеру (`duration`), хвостик-указатель, автовыбор стороны раскрытия. Но: +- `BasePopover` — `internal`, наружу доступны только готовые `Tooltip`/`Popover`, которые не дают управлять геометрией контента (нужно для анимированной shape с «шейкой» между иконкой и телом бабла). +- Трекинг позиции триггера в `Popover` — `ViewTreeObserver.OnPreDrawListener` на весь `rootView` + пересчёт экранных координат через `getLocationOnScreen` (`BasePopoverTrigger.android.kt`) — рассчитан на триггер где угодно на экране и системный `Popup`, который должен уворачиваться от клавиатуры/системных отступов/краёв окна. Для фиксированной сетки карточек это избыточно. +- Базовый DS `Card` (`Card.kt:318`) клипается по своей форме — бабл, чтобы наезжать на соседние карточки, не может быть просто дочерним элементом клипающегося контейнера. + +Форма бабла в Figma — булева операция `Union` между телом (rounded rect) и кругом иконки; в кодовой базе уже есть тот же приём для хвостика тултипа (`Path.op(backgroundPath, tailPath, PathOperation.Union)`, `BasePopover.kt:479`), но код специфичен для `Popover` и недоступен снаружи. + +## Goals / Non-Goals + +**Goals:** +- Поставить компонент `UpsellBubble` (Trigger + Host) в `tokens/plasma.homeds.compose`, по конвенции вертикали (аналог `NumberPanel`: свой `Style`/`MotionStyle`/`Builder`, ручные style-bindings, sandbox `Story`). +- Дать корректное отслеживание позиции триггера при скролле/релэйауте сетки без системного `Popup`. +- Реализовать анимацию: морфинг иконки звёздочка↔крестик, разворот бабла с «шейкой» (Union-геометрия), автосворачивание через 5 сек, раздельные исходы закрытия (таймаут → сворачивание, крестик → исчезновение насовсем). +- Оставить компонент свободным от бизнес-логики (приоритет статусов, персист «нового предложения», навигация) — только props/колбэки. + +**Non-Goals:** +- Поддержка системного `Popup`/окна как альтернативной стратегии позиционирования — не строим даже как задел; при появлении второго реального сценария использования (не сетка) это отдельное изменение. +- Полный набор стилевых вариаций (size/view, как `Xs`/`S`/`M`/`Humidity` у `NumberPanel`) — нет законченной дизайн-спеки с вариациями, делаем только `Default`. +- Защита бабла от перекрытия системными диалогами/шторками/`OverlayHost`-тостами — сознательно принятый риск (см. Risks). +- Сам экран «Мой дом», сетка устройств, приоритет статус-бейджей, переход на экран апсейла, персист «нового предложения» — код отдельного consumer-приложения, не этого репозитория. + +## Decisions + +### 1. Компонент — в `tokens/plasma.homeds.compose`, не в `sdds-core/uikit-compose` +По аналогии с `NumberPanel`. Апсейл — продуктовая, специфичная для SberHome концепция, переиспользование в других брендах не предполагается. + +### 2. Не наследуемся от `Tooltip`/`Popover` — позиционирование пишется с нуля +*Альтернатива А*: переиспользовать `internal BasePopover` — невозможно без изменения видимости в `uikit-compose` (чужой модуль, чужой релизный цикл). +*Альтернатива Б*: расширить публичное API `uikit-compose` новым общим примитивом (аналог `BasePopover`, но публичным), которым пользовались бы и `Tooltip`/`Popover`, и наш `UpsellBubble`. Отклонено на этом этапе — нет второго реального потребителя такого примитива, дополнительная зависимость по срокам от чужого бэклога ради гипотетического переиспользования. +Выбрано: `UpsellBubble` не зависит от кода `Popover` вообще, пишет собственное (более узкое) позиционирование. + +### 3. Единственная стратегия позиционирования — overlay в общем `Box`, без параметра "стратегия" +Рассматривался `sealed`-параметр `UpsellBubbleHosting` (`Overlay` | `Window`, где `Window` мог бы переиспользовать публичный `Popover` через `content`-слот). Отклонено: нет конкретного второго сценария использования вне сетки устройств — добавление непроверенного продуктом пути только увеличивает тестовую поверхность и риск расхождения поведения между стратегиями при будущих правках анимации. Компонент и стейт-машина написаны так, что контент (форма/анимация) архитектурно отделён от способа монтирования — если стратегия `Window` понадобится позже, это точечное расширение, а не переписывание. + +### 4. Трекинг позиции триггера — обычный layout-проход Compose, не `ViewTreeObserver` +`UpsellBubbleHost` монтируется один раз вокруг сетки устройств; `UpsellBubbleTrigger` внутри каждой карточки репортит свою позицию относительно этого общего `Box` через `onGloballyPositioned`. Скролл сетки, смена числа колонок, поворот экрана — все синхронно пересчитываются тем же layout-проходом, что и сама сетка, без отдельных listener'ов и конвертации в экранные координаты. +*Обоснование*: в отличие от `Popover`, триггер живёт в известном, ограниченном контейнере (сетке), а не где угодно на экране — специализированное решение дешевле и надёжнее общего. + +### 5. Геометрия бабла — `Path.op(..., PathOperation.Union)` с анимируемыми параметрами +Тело бабла, «шейка»-перемычка и круг иконки-триггера собираются в единый путь через объединение (по мотивам приёма из `BasePopover.kt:479`, реализация независимая — код `Popover` не переиспользуется, см. Decision 2). Радиус круга, ширина/длина шейки и позиция корпуса — анимируемые величины (`Animatable`/`animateDpAsState`) внутри `drawWithCache`, поэтому путь пересчитывается на каждый кадр анимации без ручной интерполяции path-морфинга. Библиотека `androidx.graphics.shapes`/`Morph` не подключается — в проекте её нет, а для этой геометрии (два монотонно меняющихся круглых контура) она не нужна. + +### 6. Морфинг иконки-триггера — кроссфейд/скейл, без готового прецедента +Прецедента анимированной смены векторных иконок в `uikit-compose` нет (`AiInput` и другие AI-компоненты этой задачи не решают). Используются существующие иконки (`ic_star_four_outline_16`, `ic_close_24`), сама анимация — новый код в `UpsellBubbleTrigger`. + +### 7. Два разных исхода закрытия — явная стейт-машина, не единый `onDismissRequest` +Состояния: `Hidden → Collapsed ⇄ Expanded`. Переход `Expanded → Collapsed` — по таймауту (5 сек), бабл остаётся на карточке в свёрнутом виде. Переход `Expanded/Collapsed → Hidden` — по клику на крестик, бабл исчезает насовсем (до следующего `hasNewOffer` от потребителя). В отличие от `Tooltip.onDismissRequest` (один колбэк на любое закрытие), это два разных, различимых снаружи исхода. + +### 8. Компонент не хранит и не знает бизнес-логику +`UpsellBubbleTrigger`/`Host` принимают `hasNewOffer: Boolean` (либо аналогичный флаг) и колбэки (`onExpandedClick` — переход на экран апсейла, `onDismissed` — для персиста «просмотрено» на стороне приложения). Приоритет между статус-бейджами устройства и баблом, а также сам факт «показывать ли бабл сейчас», решает вызывающий код — компонент это не проверяет. + +### 9. Z-order относительно системных модалок — не решается в этом изменении +Продуктовое решение подтверждено окончательно: модалки/шторки могут перекрывать бабл, компонент не пытается закрываться сам при их появлении. Если потребуется — это отдельное расширение API `Host` (например, `LocalModalVisible`/явный `dismiss()`), не блокирует текущий скоуп. + +### 10. Только стилевая вариация `Default` +Нет готовой дизайн-спеки с размерными/видовыми вариациями (как `Xs`/`S`/`M`/`Humidity` у `NumberPanel`). Публичный `UpsellBubbleStyle` проектируется с тем же контрактом (`Style`/`Builder`), что и у `NumberPanel`, чтобы добавление вариаций позже было аддитивным, но сейчас заводится только одна инстанция стиля. + +### 11a. Контент развёрнутого бабла — пользовательский слот, не фиксированные под-части +Развёрнутый бабл принимает `content: @Composable () -> Unit` — произвольное содержимое от вызывающей стороны (текст, прогресс-индикатор, что угодно), а не фиксированный набор «текст + шеврон + прогресс-бар», как изначально предполагалось по скриншоту Figma. Компонент не разбирает и не типизирует этот слот; единственная его обязанность — предоставить слоту цвет текста из стиля через `CompositionLocalProvider`/`ProvideTextStyle` (по паттерну, которым `NumberPanel` прокидывает цвет в свой `icon`-слот через `LocalTintBrushProducer`, и которым `AiInput` прокидывает `placeholderColorState` через `ProvideTextStyle`). +*Обоснование*: снимает открытый вопрос «прогресс-бар — живой таймер или декоративный `Badge`» — это теперь не забота компонента, а содержимое слота на усмотрение консьюмера. + +### 11b. Тайминг анимации — принятое по умолчанию решение +Точный тайминг из Figma-прототипа не был подтверждён (недоступен без логина в этой сессии); решение принято как разумный дефолт, без блокировки на дизайн-ревью: +- **Разворот (Collapsed → Expanded)**: геометрия тела/шейки растёт 300 мс с `FastOutSlowIn` — длительность согласована с уже существующим прецедентом «растущей карточки» в этой кодовой базе (`OverlayHost.ANIMATION_DURATION = AnimationConstants.DefaultDurationMillis`, `OverlayHost.kt`/`OverlayManager.kt:122`, тот же `expandVertically`/`tween` для похожего эффекта появления карточки). +- Морфинг иконки (звезда → крестик) — кроссфейд короче, ~150–200 мс, начинается одновременно с началом роста тела: иконка-источник трансформации завершает свою метаморфозу раньше, чем раскрывается всё тело — стандартный choreography-приём (триггер отрабатывает первым, то, что «появляется из него», чуть отстаёт). +- Контент-слот появляется с небольшой задержкой (~80–100 мс) относительно начала роста тела (staggered reveal) — не выглядит «приклеенным» к ещё формирующейся форме. +- **Сворачивание по таймауту (Expanded → Collapsed)**: обратный процесс, чуть быстрее разворота — 200 мс. +- **Исчезновение насовсем (→ Hidden, по крестику)**: не сворачивание в иконку, а простой `fade + scale` всего бабла целиком, 200 мс — ощущается как «прощание», а не как переход в другое видимое состояние. + +*Открытый момент*: эти цифры не сверены с автором макета — при появлении доступа к прототипу или дизайн-ревью стоит перепроверить и скорректировать при расхождении. + +### 11. Без themebuilder/CMP-генератора — все файлы пишутся вручную +В `playground/theme-builder` нет конфигурации для `NumberPanel` — он уже полностью написан вручную, генератор используется только для компонентов, раскатанных по общему пайплайну (`api-info`/themebuilder rollout). `UpsellBubble` идёт по тому же ручному пути: `UpsellBubbleStyle.kt`/`MotionStyle.kt` (контракты), `styles/customcomponents/upsellbubble/UpsellBubbleStyles.kt` (привязка к токенам темы, аналог `NumberPanelStyles.kt`), `integration/.../PlasmaHomedsUpsellBubbleVariationsCompose.kt` (screenshot-фикстура), `sandbox/strories/upsellbubble/UpsellBubbleStory.kt` (песочница). + +## Risks / Trade-offs + +- **[Риск] Тайминг анимации (Decision 11b) — принятый дефолт, не сверенный с автором макета.** → Смягчение: пересмотреть при появлении доступа к прототипу/дизайн-ревью; архитектура (стейт-машина, геометрия) от конкретных цифр не зависит, правка тайминга — точечная. +- **[Риск] Собственная Union-геометрия и морфинг иконки — код без прецедента в кодовой базе, копировать не с чего.** → Смягчение: обкатать в изоляции через `UpsellBubbleStory` до интеграции в `Trigger`/`Host`. +- **[Риск] Overlay-подход требует, чтобы consumer-приложение обернуло свою сетку устройств в `UpsellBubbleHost`.** Мы не видим реальный код экрана «Мой дом» из этого репозитория и не можем проверить, насколько легко это встроить. → Смягчение: чётко задокументировать двухчастный контракт (KDoc + пример в `UpsellBubbleStory`). +- **[Trade-off] Нет фолбэка на системный `Popup` — бабл может быть перекрыт модалками/диалогами приложения.** Осознанно принято (см. Decision 9), в рамках этого изменения не смягчается. +- **[Trade-off] Только `Default`-вариация ограничивает переиспользование текущей формой/размером.** Добавление size/view-вариаций — отдельное изменение, когда появится полноценная дизайн-спека. + +## Open Questions + +- Тайминг анимации (Decision 11b) принят как разумный дефолт без сверки с автором макета — пересмотреть при появлении доступа к прототипу/дизайн-ревью. diff --git a/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/proposal.md b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/proposal.md new file mode 100644 index 0000000000..55002e35aa --- /dev/null +++ b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/proposal.md @@ -0,0 +1,36 @@ +## Why + +Продукту нужна механика апсейла на главном экране «Мой дом»: анимированный бабл-подсказка на карточке устройства, который разворачивается в предложение и ведёт на экран апсейла. Разработчики вертикали попросили сделать это через дизайн-систему — не кастомить с нуля и ускорить разработку. Готового компонента с такой анимацией (иконка-триггер, разворачивающийся бабл с автосворачиванием) в ДС нет; ближайший прецедент по механике — `Tooltip`/`Popover` в `sdds-core/uikit-compose` — не подходит по архитектуре (см. `design.md`), а сам компонент по природе специфичен для одного продукта и не должен попадать в общий `uikit-compose`. + +## What Changes + +- Новый компонент **`UpsellBubble`** добавляется в `tokens/plasma.homeds.compose` — по аналогии с `NumberPanel`: компонент вертикали, не часть `sdds-core/uikit-compose`. +- Публичный API — двухчастный: `UpsellBubbleTrigger` (иконка-значок, кладётся внутрь карточки устройства) + `UpsellBubbleHost` (монтируется один раз вокруг сетки устройств, владеет позиционированием и рендерит развёрнутый бабл). +- Позиционирование — собственный overlay в общем `Box`-родителе с сеткой (без системного `Popup`/окна). Трекинг позиции триггера идёт штатным layout-проходом Compose, без выделенной подсистемы отслеживания положения на экране. +- Форма бабла (тело + шейка + круг иконки) рисуется как единая анимируемая геометрия (`Path.op(..., PathOperation.Union)`, по мотивам приёма из `BasePopover.kt`, но без зависимости от кода `Popover`). +- Иконка-триггер анимированно переключается между звёздочкой и крестиком (кроссфейд/скейл); используются существующие иконки ДС (`ic_star_four_outline_16`, `ic_close_24`), новых иконок не требуется. +- Стейт-машина показа: Скрыт → Свёрнут → (клик) → Развёрнут (5 сек) → по таймауту → Свёрнут; из Развёрнутого по клику на крестик → Скрыт насовсем. Это два разных исхода закрытия, не единый `onDismissRequest`, как в `Tooltip`. +- Содержимое развёрнутого бабла — пользовательский `content`-слот (произвольный composable от вызывающей стороны), а не фиксированный набор текст/шеврон/индикатор. Компонент предоставляет слоту только цвет текста из стиля. +- Стилевой контракт компонента (`UpsellBubbleStyle`, `UpsellBubbleMotionStyle`) заводится по паттерну `NumberPanelStyle`/`NumberPanelMotionStyle` — вручную, без themebuilder/CMP-генератора (для vertical-only компонентов пайплайн генератора не применяется — `NumberPanel` тоже не генерируется). +- На первом этапе — **одна-единственная стилевая вариация `Default`**, без набора size/view-вариаций (полноценного макета с вариациями пока нет; при появлении дизайн-спеки размеры/варианты добавляются отдельным изменением). +- В песочницу `tokens/plasma.homeds.compose/app` добавляется `UpsellBubbleStory`. +- Компонент не содержит бизнес-логики: приоритет между статус-бейджами устройства и баблом апсейла, признак «есть новое предложение», персист «просмотрено» и переход на экран апсейла по клику — ответственность вызывающего приложения (передаются как параметры/колбэки). +- Компонент сознательно не защищается от перекрытия системными диалогами/шторками — это принятый риск, т.к. фолбэк на системный `Popup`/окно не предусмотрен (см. `design.md`, Non-Goals). + +## Capabilities + +### New Capabilities +- `homeds-upsell-bubble-component`: компонент `UpsellBubble` (Trigger + Host) — публичный API, стейт-машина показа/сворачивания/закрытия, анимация геометрии и иконки-триггера, позиционирование overlay без системного `Popup`, единственная стилевая вариация `Default`. + +### Modified Capabilities +*(нет — в `openspec/specs/` не существует ранее задокументированной capability для компонентов `tokens/plasma.homeds.compose`, требования переносить неоткуда)* + +## Impact + +- **Модуль**: `tokens` (included build) → `tokens/plasma.homeds.compose` — новый компонент, стиль, motion-контракт, ручные style-bindings (аналог `NumberPanelStyles.kt`). +- **Публичный API**: новый публичный API вертикали `plasma.homeds.compose` (`UpsellBubbleTrigger`, `UpsellBubbleHost`, `UpsellBubbleStyle`) — additive, не затрагивает `sdds-core/uikit-compose` и другие бренд-токены. +- **Кодогенерация**: не затрагивается — компонент вне periметра themebuilder/CMP-генератора, как и `NumberPanel`. +- **Иконки**: новых ассетов не требуется, переиспользуются существующие `sdds-core/icons`. +- **Сэндбокс/демо**: `tokens/plasma.homeds.compose/app` — новая `UpsellBubbleStory`; `tokens/plasma.homeds.compose/integration` — screenshot-фикстура по аналогии с `PlasmaHomedsNumberPanelVariationsCompose.kt`. +- **Вне скоупа этого репозитория**: экран «Мой дом», сетка устройств, приоритет статус-бейджей, переход на экран апсейла, персист «нового предложения» — это код отдельного consumer-приложения, которое зависит от `tokens/plasma.homeds.compose` и в этом монорепо отсутствует. Изменение поставляет только сам компонент и его публичный контракт. +- **Валидация**: `./gradlew tokens:plasma.homeds.compose:test`, `tokens:plasma.homeds.compose:detekt`, `tokens:plasma.homeds.compose:spotlessApply`; сборка песочницы — `tokens:plasma.homeds.compose:app:assembleDebug`. diff --git a/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/specs/homeds-upsell-bubble-component/spec.md b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/specs/homeds-upsell-bubble-component/spec.md new file mode 100644 index 0000000000..52190af6a1 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/specs/homeds-upsell-bubble-component/spec.md @@ -0,0 +1,127 @@ +## ADDED Requirements + +### Requirement: Разворот бабла по клику на триггер +Компонент SHALL отображать в свёрнутом виде триггер-иконку на карточке устройства; клик по свёрнутому триггеру SHALL переводить бабл в развёрнутое состояние. + +#### Scenario: Клик по свёрнутому триггеру +- **WHEN** пользователь кликает по свёрнутой иконке-триггеру (состояние Collapsed) и выше по дереву смонтирован `BubbleHost` +- **THEN** компонент переходит в состояние Expanded и показывает развёрнутый бабл с пользовательским содержимым + +#### Scenario: Клик без смонтированного BubbleHost +- **WHEN** пользователь кликает по свёрнутому триггеру, но `BubbleHost` не смонтирован выше по дереву +- **THEN** клик не производит эффекта — триггер продолжает работать как обычная свёрнутая иконка, разворот не происходит + +### Requirement: Уведомление о клике по триггеру +Клик по свёрнутому триггеру SHALL опционально вызывать предоставленный вызывающей стороной колбэк уведомления, не передавая ему управление самим переходом в развёрнутое состояние. + +#### Scenario: Клик по триггеру с колбэком уведомления +- **WHEN** пользователь кликает по свёрнутому триггеру и вызывающая сторона передала колбэк уведомления о клике по триггеру +- **THEN** компонент сам переводит бабл в состояние Expanded и после этого вызывает переданный колбэк; колбэк не может отменить или изменить разворот + +### Requirement: Автоматическое сворачивание по таймауту +Развёрнутый бабл SHALL автоматически возвращаться в свёрнутое состояние через настраиваемый стилем интервал после разворота, если пользователь не произвёл иное действие. + +#### Scenario: Бездействие пользователя в течение таймаута +- **WHEN** бабл находится в состоянии Expanded и в течение интервала, заданного токеном стиля автосворачивания (по умолчанию 5 секунд), не было клика по крестику или по телу бабла +- **THEN** компонент переходит в состояние Collapsed (свёрнутый триггер остаётся на карточке) + +### Requirement: Явное закрытие скрывает бабл насовсем +Клик по иконке-крестику в развёрнутом состоянии SHALL скрывать бабл полностью (Hidden) на время жизни текущего экземпляра компонента, в отличие от автоматического сворачивания по таймауту, которое оставляет свёрнутый триггер на карточке. + +#### Scenario: Клик по крестику +- **WHEN** пользователь кликает по иконке-крестику, пока бабл в состоянии Expanded +- **THEN** компонент переходит в состояние Hidden — ни развёрнутый бабл, ни свёрнутый триггер на карточке не отображаются до конца жизни этого экземпляра + +### Requirement: Показ и скрытие управляются композицией, а не флагом +Показ либо скрытие бабла SHALL быть ответственностью вызывающей стороны через монтирование либо размонтирование composable-компонента триггера; компонент SHALL не принимать отдельный входной флаг наличия предложения. + +#### Scenario: Компонент не смонтирован +- **WHEN** вызывающая сторона не размещает триггер в дереве композиции для конкретной карточки +- **THEN** ни свёрнутый триггер, ни развёрнутый бабл для этой карточки не отображаются + +#### Scenario: Новое предложение после явного закрытия +- **WHEN** триггер был явно закрыт пользователем (состояние Hidden) и требуется показать новое предложение +- **THEN** вызывающая сторона монтирует новый экземпляр компонента (например, с другим ключом), а не переиспользует признак наличия предложения на уже смонтированном экземпляре + +### Requirement: Переход на экран апсейла по клику на развёрнутый бабл +Клик по телу развёрнутого бабла (вне зоны крестика) SHALL вызывать предоставленный вызывающей стороной колбэк перехода на экран апсейла; сам компонент SHALL не выполнять навигацию самостоятельно. + +#### Scenario: Клик по телу развёрнутого бабла +- **WHEN** пользователь кликает по развёрнутому баблу вне области иконки-крестика +- **THEN** компонент вызывает переданный колбэк клика по баблу и не меняет своё состояние показа самостоятельно + +### Requirement: Морфинг иконки-триггера между звёздочкой и крестиком +Иконка триггера SHALL анимированно переключаться между изображением звёздочки (состояние Collapsed) и крестика (состояние Expanded) при каждом переходе между этими состояниями; обе иконки SHALL браться из compose-пака иконок дизайн-системы и SHALL задаваться стилем. + +#### Scenario: Переход Collapsed → Expanded +- **WHEN** триггер переходит из свёрнутого состояния в развёрнутое +- **THEN** иконка триггера анимированно меняется со звёздочки на крестик + +#### Scenario: Переход Expanded → Collapsed +- **WHEN** триггер переходит из развёрнутого состояния в свёрнутое (по таймауту) +- **THEN** иконка триггера анимированно меняется с крестика обратно на звёздочку + +### Requirement: Бабл отслеживает позицию триггера без системного Popup +Развёрнутый бабл SHALL визуально позиционироваться относительно текущего положения своего триггера и SHALL оставаться корректно позиционированным при скролле или изменении раскладки сетки устройств, без использования системного `Popup`/отдельного окна. + +#### Scenario: Скролл сетки при развёрнутом бабле +- **WHEN** пользователь скроллит сетку устройств, пока бабл находится в состоянии Expanded +- **THEN** развёрнутый бабл перемещается синхронно с триггером, оставаясь визуально привязанным к его текущей позиции + +#### Scenario: Бабл выходит за пределы клипа своей карточки +- **WHEN** бабл развёрнут на карточке, ширина которой меньше ширины развёрнутого бабла +- **THEN** бабл визуально отображается поверх соседних карточек сетки, не обрезаясь границей своей собственной карточки + +### Requirement: Размещение и выравнивание тела относительно триггера +Компонент SHALL позволять вызывающей стороне явно задавать, с какой стороны от триггера появляется тело бабла, и его выравнивание вдоль перпендикулярной оси; компонент SHALL также поддерживать автоматический выбор стороны и выравнивания по свободному месту в границах хоста. + +#### Scenario: Явное указание стороны и выравнивания +- **WHEN** вызывающая сторона передаёт конкретную сторону (сверху/снизу/с начала/с конца) и выравнивание +- **THEN** тело бабла растёт именно в указанную сторону и выравнивается вдоль перпендикулярной оси согласно переданному значению, зеркалируясь под RTL там, где это применимо + +#### Scenario: Автовыбор стороны и выравнивания +- **WHEN** вызывающая сторона запрашивает автоматический выбор стороны +- **THEN** в момент разворота компонент измеряет свободное место от круга-якоря до границ хоста по всем сторонам, выбирает сторону с максимумом свободного места и подбирает выравнивание вдоль перпендикулярной оси по той же логике + +### Requirement: Компонент не хранит бизнес-состояние +Компонент SHALL не содержать собственной логики приоритета относительно статус-бейджей устройства и SHALL не определять самостоятельно, показывать ли бабл для конкретного устройства — эта ответственность лежит на вызывающей стороне. + +#### Scenario: Приоритет среди статус-бейджей карточки +- **WHEN** на одной карточке устройства потенциально может отображаться несколько статус-бейджей одновременно с баблом +- **THEN** выбор, что показывать, делает вызывающая сторона до монтирования компонента; сам компонент не содержит логики сравнения с другими бейджами + +### Requirement: Пользовательский слот содержимого развёрнутого бабла +Развёрнутый бабл SHALL принимать произвольное содержимое от вызывающей стороны как composable-слот и SHALL не накладывать ограничений на его структуру. Компонент SHALL предоставлять этому слоту цвет текста и типографику из текущего стиля неявно, через локальные провайдеры. + +#### Scenario: Слот получает цвет текста и типографику из стиля +- **WHEN** вызывающая сторона размещает текстовый элемент внутри слота содержимого бабла, не указывая цвет или типографику явно +- **THEN** текст отображается цветом и типографикой, заданными в `BubbleStyle` для текущей вариации + +#### Scenario: Произвольное содержимое слота +- **WHEN** вызывающая сторона размещает в слоте содержимое, отличное от простого текста (например, индикатор прогресса) +- **THEN** компонент отображает это содержимое как есть, не изменяя и не проверяя его структуру + +### Requirement: Перегрузка с текстом, шевроном и прогресс-баром +Компонент SHALL предоставлять готовую перегрузку для основного сценария использования — текст с иконкой-шевроном в конце строки и опциональным прогресс-баром под текстом — без необходимости вручную собирать это содержимое через content-слот. + +#### Scenario: Использование перегрузки с текстом и прогрессом +- **WHEN** вызывающая сторона использует перегрузку с параметрами `text` и `progress` +- **THEN** компонент сам собирает текст со встроенной иконкой-шевроном (привязанной к последнему слову последней строки текста) и прогресс-бар под ним, отображающий переданное значение прогресса + +#### Scenario: Прогресс-бар скрыт +- **WHEN** вызывающая сторона указывает, что прогресс-бар показывать не нужно +- **THEN** компонент полностью убирает прогресс-бар вместе с отступом под него, а не только визуально скрывает его с сохранением места + +### Requirement: Радиусы скругления и иконки задаются стилем +Все радиусы скругления, используемые в отрисовке компонента (тело, ближний к триггеру угол, галтель шейки, торцы сегментов прогресс-бара), и обе иконки триггера (звёздочка, крестик) SHALL быть настраиваемыми токенами стиля, а не константами, зашитыми в код отрисовки. + +#### Scenario: Кастомный стиль с иными радиусами и иконками +- **WHEN** вызывающая сторона передаёт стиль с явно указанными значениями радиусов скругления и/или иными иконками звёздочки/крестика +- **THEN** компонент отрисовывает тело, шейку, прогресс-бар и иконки триггера согласно переданным значениям, а не согласно значениям по умолчанию + +### Requirement: Единственная стилевая вариация Default +Публичный стилевой контракт компонента SHALL предоставлять ровно одну готовую вариацию — `Default`; набор размерных/видовых вариаций SHALL быть добавлен отдельным изменением при появлении соответствующей дизайн-спеки. + +#### Scenario: Использование компонента без явного указания стиля +- **WHEN** вызывающий код использует `BubbleTrigger`/`BubbleHost` без явного указания стиля +- **THEN** применяется единственная доступная вариация `Default` diff --git a/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/tasks.md b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/tasks.md new file mode 100644 index 0000000000..d7eda93cde --- /dev/null +++ b/openspec/changes/archive/2026-09-10-add-homeds-upsell-bubble/tasks.md @@ -0,0 +1,576 @@ +## 1. Уточнения перед реализацией + +- [x] 1.1 Тайминг анимации — принят дефолт без сверки с прототипом (см. `design.md`, Decision 11b: разворот 300 мс/`FastOutSlowIn`, морфинг иконки ~150–200 мс, контент-слот с задержкой ~80–100 мс, сворачивание 200 мс, исчезновение 200 мс `fade+scale`); пересмотреть при появлении доступа к прототипу/дизайн-ревью +- [x] 1.2 Природа полоски под текстом снята как открытый вопрос — содержимое развёрнутого бабла реализуется как пользовательский `content`-слот (см. `design.md`, Decision 11a; `specs/homeds-upsell-bubble-component/spec.md`, «Пользовательский слот содержимого развёрнутого бабла»), компонент не разбирает и не типизирует его + +## 2. Публичный API и стейт-машина (`tokens/plasma.homeds.compose`) + +- [x] 2.1 Завести пакет `com.sdds.plasma.homeds.components.upsellbubble` (по аналогии с `components.numberpanel`) +- [x] 2.2 Реализовать стейт-машину показа: `Hidden` / `Collapsed` / `Expanded`, с переходами по клику, таймауту (5 сек) и явному закрытию +- [x] 2.3 Реализовать `UpsellBubbleTrigger` — свёрнутая иконка внутри карточки устройства, принимает `hasNewOffer`/аналогичный входной флаг, колбэки `onExpandedClick`, `onDismissed` +- [x] 2.4 Реализовать `UpsellBubbleHost` — контейнер вокруг сетки устройств, владеет позиционированием развёрнутого бабла +- [x] 2.5 Добавить параметр `content: @Composable () -> Unit` для развёрнутого бабла — произвольный слот без фиксированной структуры (текст/шеврон/индикатор — забота вызывающей стороны) +- [x] 2.6 Прокинуть в слот `content` цвет текста из стиля через `CompositionLocalProvider` (`LocalTextBrushProducer`/`LocalTintBrushProducer`, по паттерну `NumberPanel`/`AiInput`) +- [x] 2.7 Добавить KDoc для публичного API (`UpsellBubbleTrigger`, `UpsellBubbleHost`) с описанием двухчастного контракта интеграции и контракта `content`-слота + +## 3. Позиционирование (Host ⇄ Trigger) + +- [x] 3.1 Реализовать репорт позиции триггера относительно общего `Box`-родителя через `onGloballyPositioned` +- [x] 3.2 Реализовать рендер развёрнутого бабла в `Host` с учётом текущей позиции активного триггера, без клипа по границе отдельной карточки +- [x] 3.3 `UpsellBubbleStory` дополнена скроллящейся `LazyVerticalGrid` (30 карточек-заглушек) с триггером внутри одной из них — структурно проверяет путь `onGloballyPositioned`/`localPositionOf` при скролле; сборка проходит (`:app:compileDebugKotlin`). Визуальная проверка на эмуляторе/устройстве не выполнялась — в этой сессии нет доступа к Android-эмулятору +- [x] 3.4 Обработать исчезновение триггера из композиции (карточка проскроллена/удалена) — скрывать бабл без краша/зависшей позиции (`DisposableEffect` в `UpsellBubbleTrigger`) + +## 4. Геометрия и анимация бабла + +- [x] 4.1 Реализовать форму (тело + шейка + круг иконки) через `Path.op(..., PathOperation.Union)` внутри `drawWithCache`, независимо от кода `Popover` — реализовано как чистая функция `buildUpsellBubblePath` (не `Shape`+`Modifier.clip`: статический `clip(shape)` не переисполняет `createOutline` на каждый кадр только из-за чтения `Animatable` внутри, нужен `drawWithCache`/`onDrawBehind`, тот же паттерн, что и в `BasePopover.kt`). **Баг №5 закрыт переработкой дизайна, не фиксом** — см. ниже. + - **Баг №2** (найден визуальной проверкой скриншотов, уже после первого отчёта пользователю): тело было сделано с перехлёстом в круг вместо настоящего зазора — бабл сидел вплотную к кружку без видимой шейки. Добавлена третья фигура (прямоугольник, `neckWidth` из стиля — эта размерность существовала в `UpsellBubbleStyle` с самого начала, но не использовалась) с реальным зазором (`upsellBubbleNeckLengthPx`) и нахлёстом в тело/круг (`NECK_OVERLAP_RATIO`), чтобы Union не оставлял шов на стыке трёх фигур («бусины» вместо одной формы). + - **Баг №3** (замечен пользователем по скриншоту): заменил прямоугольник на "песочные часы" (сужение к середине через `cubicTo`) — визуально ближе, но пользователь уточнил диагноз точнее: на Figma-макете сама шейка **прямоугольная** (прямые параллельные стороны), а плавность даёт не сужение, а **вогнутые галтели ровно в двух стыках** — там, где прямая сторона шейки встречается с контуром тела и контуром круга (тот же приём, что и в `BasePopover.kt` для хвостика тултипа — `addArc` вместо острого угла). Переписано на `buildFilletedNeckPath`: прямоугольник + 4 галтели (четверть-окружности радиуса `neckWidth * NECK_FILLET_RATIO`), через `Path.arcTo`/`lineTo`/`close`, геометрия углов проверена алгебраически перед реализацией. + - **Баг №4** (пользователь снова не увидел галтели на скриншоте): арифметика констант была несогласована — `NECK_OVERLAP_RATIO=0.6` при `NECK_LENGTH_RATIO=0.5` и `NECK_FILLET_RATIO=0.7` давала прямой участок шейки между двумя галтелями всего ~1.6px (при нужных >16.8px) — обе галтели с каждой стороны фактически схлопывались друг в друга, оставляя только шов. Диагностировано через изолированные debug-тесты: `buildFilletedNeckPath` в отрыве (Canvas, без Union) и `buildUpsellBubblePath` целиком на большом/реальном размере, оба показали ЧИСТУЮ форму без артефактов — значит проблема была в константах, не в геометрии. Пересчитаны на `NECK_LENGTH_RATIO=0.6`, `NECK_OVERLAP_RATIO=0.25`, `NECK_FILLET_RATIO=0.5` (straight run ~13px против нужных >12px). + - **Баг №5, НЕ РЕШЁН** (пользователь: «галтели выродились, торчат острые куски наружу»): на скриншоте `testExpandedShowsBubbleBody` действительно виден острый угол справа (галтель отсутствует) и мелкий шип слева — подтверждено сглаженным (Lanczos) increase, не только артефакт nearest-neighbor апскейла. Потрачено значительное время на локализацию: + - 3 изолированных воспроизведения (`buildUpsellBubblePath` напрямую на большом размере / на реальном размере 32dp/12dp квадратом / на реальных широких-невысоких пропорциях контейнера, как у настоящего виджета) — **все чистые**, без артефактов. + - Точная копия обёрточной структуры `UpsellBubbleOverlayItem`/`UpsellBubbleBody` (zIndex+graphicsLayer+drawWithCache+clickable+вложенные Box) с `progress` как константой (1f и 0.985f) — **чистая**. + - Та же копия с настоящим `Animatable`+`coroutineScope{launch;launch}` под ручным управлением `composeTestRule.mainClock` (как в падающем тесте) — **чистая**. + - Попытка залогировать реальные значения (`println`, `@Volatile`-переменная модуля, запись в файл) прямо из `onDrawBehind` реального `UpsellBubbleHost.kt` — **не сработала ни разу**, несмотря на то что сама форма корректно рисуется на скриншоте того же прогона; причина не установлена (подозрение на особенность рендер-пути Robolectric `GraphicsMode.NATIVE`/Roborazzi `captureScreenRoboImage()`, не воспроизведённую ни в одном ручном приближении). + - Вывод: баг воспроизводится только в самом `UpsellBubbleTrigger`+`UpsellBubbleHost`+клик через `performClick()` целиком — не удалось изолировать, какая именно часть (сам Trigger, реестр Host'а или Robolectric-специфика захвата скриншота) отвечает за расхождение. Все временные debug-тесты и добавленный debug-код в `UpsellBubbleHost.kt` удалены после локализации, изменений в геометрии `UpsellBubbleShape.kt` по этому пункту не вносилось (числа те же, что после Бага №4). + - **Упрощение дизайна по запросу пользователя**: галтель на стыке шейки с кругом убрана полностью (острый угол, `lineTo`), оставлена только на стыке шейки с телом, с собственным настраиваемым радиусом (`UpsellBubbleDimensions.neckFilletRadius`, дефолт `4.dp`). После этого изменения первый же реальный скриншот снова показал асимметрию (пользователь: «видно, что слева галтель есть, а справа её нет вообще») — предыдущий отчёт о решении Бага №5 был **преждевременным**, визуальная проверка была недостаточно тщательной (беглый просмотр не при должном приближении). + - **Баг №5, теперь РЕШЁН — найдены две реальные (не мистические) причины**, на этот раз с полной изоляцией и воспроизведением вне `Trigger`/`Host`/click-пайплайна (в отличие от прошлых заходов): + 1. **Галтель "съедалась" нахлёстом**: `neckTopY` считался как `bodyBottom - overlapPx`, где `overlapPx = neckWidth × 0.25` (3dp) был того же порядка, что и сам радиус галтели (4dp) — видимой (не перекрытой телом) оставалась только `radius - overlap` ≈ 1dp дуги, неотличимая на глаз от острого угла. Исправлено отдельной, гораздо меньшей верхней подложкой `topOverlapPx = neckFilletRadiusPx × 0.15`, независимой от бокового `overlapPx` у круга (используется только для защиты от щели на стыке float-координат в Union, не съедает саму галтель). + 2. **Self-intersection "шип" на стороне, ближней к краю карточки**: круг-якорь стоит вплотную к краю тела (`circleLeft = 0`), поэтому шейка (при дефолтных `circleSize=32dp`, `neckWidth=12dp`) расположена внутри зоны скругления ближнего угла тела (`bodyCornerRadius=16dp` > `leftX=10dp`) — дуга галтели шейки и дуга скругления угла тела почти касаются друг друга, и `Path.op(..., Union)` на стыке двух почти совпадающих кривых давал тонкий самопересекающийся артефакт («шип», торчащий наружу). Исправлено: угол тела со стороны круга-якоря сведён к 0 через 4-угловой `RoundRect(topLeft, topRight, bottomRight, bottomLeft)` вместо единого `CornerRadius` на все 4 угла — в развёрнутом состоянии это место всё равно полностью перекрыто шейкой и кругом, свой скруглённый угол там невидим, а конфликтующей кривой стало неоткуда взяться. Дальний от круга угол остаётся с обычным `bodyCornerRadiusPx`. + - Методика локализации (в отличие от прошлых безуспешных попыток): написан временный `UpsellBubbleShapeDebugTest.kt`, вызывающий `buildUpsellBubblePath` напрямую (без `Trigger`/`Host`/click) при увеличенном масштабе (6×) — на этот раз баг **воспроизвёлся и в изоляции** (в отличие от прошлых заходов, когда изолированные репродукции были чисты, а причина расхождения с реальным пайплайном осталась загадкой). Дополнительными debug-тестами (голая `buildNeckPath` без Union; Union шейки с телом при разных `overlapPx`/`cornerRadius`/центрированием) причины разделены и подтверждены по отдельности до фикса в основном коде. Все debug-тесты и debug-скриншоты удалены после локализации. + - Финальная проверка (после обоих фиксов): `testExpandedShowsBubbleBody`/`testExpandedBubbleOverlapsNarrowNeighborCards` пересняты (`-Proborazzi.test.record=true`), оба стыка визуально чистые и симметричные на Lanczos-приближении вплоть до пиксельного уровня, без спайков и без острых углов там, где ожидается галтель. + - **Баг №6** (пользователь, после предыдущего фикса): «скругление угла 90° должно плавно сливаться с обеими линиями... соединение галтели с прямыми — неплавное, видно линию вместо аккуратной дуги». Причина: `neckTopY` считался через отдельный `lerp(..., bodyFinalRect.bottom - topOverlapPx, p)`, а не напрямую из `bodyRect.bottom` — дуга галтели строится так, что её касательная горизонтальна ТОЛЬКО в самой верхней точке (`topY`, угол 270°); при любом ненулевом `topOverlapPx` видимой становится не вся дуга от этой точки, а обрезанный кусок ближе к её концу, где касательная уже не горизонтальна — на стыке с плоским краем тела это даёт заметный излом (видна прямая вместо дуги), а не саму галтель. Исправлено: `neckTopY = bodyRect.bottom` — та же переменная, что и у тела (не отдельный lerp), поэтому вся дуга целиком открыта наружу и её горизонтальная касательная в точности совпадает с плоским краем тела; побочный эффект — щель на стыке float-координат больше не нужно чем-то компенсировать (совпадение точное, не приближённое), константа `NECK_TOP_SEAM_RATIO` удалена как ненужная. Проверено на пиксельном приближении (20×, Lanczos) — дуга гладко касается обеих прямых на обоих стыках без излома. +- [x] 4.2 Анимировать параметры геометрии (радиус круга, ширина/длина шейки, позиция корпуса) через `Animatable` +- [x] 4.3 Реализовать морфинг иконки-триггера звёздочка ↔ крестик (кроссфейд/скейл) на существующих иконках (`ic_star_four_outline_16`, `ic_close_24`) +- [x] 4.4 Свести анимацию геометрии, морфинга иконки и появления `content`-слота в общий таймлайн по тайминг-плану из `design.md` (Decision 11b). **Баг, найденный визуальной проверкой скриншотов** (см. 8.1): `expandProgress.animateTo(...)` и `iconMorph.animateTo(...)` были на последовательных suspend-вызовах — иконка не начинала морфинг, пока тело не доезжало до конца (300 мс), вместо одновременного старта. Исправлено на `coroutineScope { launch {...}; launch {...} }` + +## 5. Стили (`UpsellBubbleStyle`) + +- [x] 5.1 Завести `UpsellBubbleStyle`/`UpsellBubbleStyleBuilder` (`Colors`, `Dimensions`) по паттерну `NumberPanelStyle.kt` +- [x] 5.2 Завести `UpsellBubbleMotionStyle`/`Motion` по паттерну `NumberPanelMotionStyle.kt`/`NumberPanelMotion.kt` +- [x] 5.3 Написать вручную `styles/customcomponents/upsellbubble/UpsellBubbleStyles.kt` — единственная вариация `Default`, привязка к токенам `PlasmaHomeDsTheme` (аналог `NumberPanelStyles.kt`, без генератора). **Баг, найденный визуальной проверкой скриншотов**: изначальный выбор `surfaceInversePromo`/`textInversePromo` в `ColorTokens.kt` этой темы буквально равен `0xFFFFFFFF` у обоих (белый на белом, невидимый текст/иконка) — токены семейства "Promo" ещё не заполнены дизайном. Заменено на `surfaceInversePositive`/`textOnDarkPrimary` (зелёный `0xFF1A9E32` + светлый текст, уже заполненные значения, ближе к Figma) — с явным примечанием в KDoc заменить на настоящие promo-токены, когда их заполнит владелец темы +- [x] 5.4 Все размеры геометрии шейки/тела вынесены в настраиваемые размерности стиля, по запросу пользователя («убедимся, что толщина и длина шейки настраивается через стиль... настраиваются все радиусы, в том числе радиус бабла около шейки»). До этого `neckWidth`/`neckFilletRadius` уже были настраиваемыми (см. выше), но длина шейки считалась внутренней константой (`upsellBubbleNeckLengthPx = circleDiameterPx × 0.6`), а угол тела рядом с кругом был жёстко занулён (см. фикс Бага №5) — не настраивался и фактически отсутствовал, хотя на Figma-макете у него есть свой (меньший, чем у остальных трёх) радиус. Добавлены две новые размерности: + - `UpsellBubbleDimensions.neckLength: StatefulValue` (дефолт `20.dp`, близко к прежнему производному значению `19.2.dp`) — заменяет `upsellBubbleNeckLengthPx`, эта функция удалена, `neckLengthPx` теперь параметр `buildUpsellBubblePath`, а `UpsellBubbleBody` в `UpsellBubbleHost.kt` читает его из стиля напрямую (та же переменная и для `reservedBottom`, и для геометрии — не разъезжаются). + - `UpsellBubbleDimensions.bodyNearCornerRadius: StatefulValue` (дефолт `4.dp`, меньше `bodyCornerRadius=16.dp`) — угол тела рядом с кругом-якорем больше не хардкодится в 0, но и не применяется "как есть": в `buildUpsellBubblePath` он проходит через безопасный клэмп (`coerceAtMost(marginToFillet × 0.8)`, где `marginToFillet` — расстояние от края тела до дуги галтели шейки) — это защищает от возврата Бага №5 (self-intersection «шип» при пересечении дуги угла с дугой галтели), даже если кто-то задаст в стиле неоправданно большое значение. При дефолтных размерах (`circleSize=32dp`, `neckWidth=12dp`, `neckFilletRadius=4dp`) безопасный предел ≈4.8dp, дефолт `4.dp` укладывается с запасом. + - Обе размерности добавлены в интерфейс/билдер/`DefaultUpsellBubbleDimensions` и продемонстрированы в `UpsellBubbleSamples.kt`. Пересняты скриншоты — угол рядом с кругом теперь визуально скруглён (меньше остальных трёх), без повторного появления артефакта; `detekt`/`spotlessCheck`/тесты по `plasma.homeds.compose` и `:docs` — чисто. +- [x] 5.5 Доп. защита по вопросу пользователя: «у трёх дальних углов может быть риск, когда радиус больше половины высоты бабла — согласен?». Да, риск реальный и подтверждён эмпирически: ни Compose `RoundRect`, ни платформенный `Path.addRoundRect` сами не ограничивают радиус относительно размеров прямоугольника (в отличие от CSS `border-radius`) — при `bodyCornerRadius` больше половины меньшей стороны тела смежные углы одной грани перекрываются, Union/растеризация даёт тот же класс self-intersection артефакта, что и Баг №5, только между двумя ДАЛЬНИМИ углами. Подтверждено временным тестом (`bodyCornerRadius(100.dp)` на теле высотой ~44dp — видимый вырез до фикса, исчез после). Исправлено в `buildUpsellBubblePath`: `maxSafeCorner = min(bodyRect.width, bodyRect.height) / 2f`, применяется к `lerpedCorner` (три дальних угла) и повторно к `safeNearCornerPx` (не только к дистанции до галтели шейки, но и к этому же общему пределу) — на каждом кадре анимации, т.к. `bodyRect` меняется по `progress`. При чрезмерном радиусе тело просто становится «таблеткой»/капсулой (аналог CSS-поведения при переполненном border-radius), а не ломается. +- [x] 5.6 `UpsellBubbleDimensions.bodyMinHeight` подключён (по итогам находки в 5.5) — раньше был объявлен в стиле, но нигде не читался, реальная высота тела полностью определялась контентом. В `UpsellBubbleHost.kt`/`UpsellBubbleBody` добавлен `Modifier.heightIn(min = bodyMinHeight)` — важен порядок модификаторов: применён на INNER-боксе (`contentPadding` + `graphicsLayer` fade), а не на OUTER (у которого `padding(bottom = reservedBottom)` под шейку/круг) — иначе минимальная высота считала бы резерв под шейку частью «тела». Проверено временным тестом (`bodyMinHeight(80.dp)` + контент 1×1dp) — тело раздувается до минимума; обычные скриншоты не изменились (дефолт 40dp меньше естественной высоты контента). + +## 6. Анимация схлопывания (по итогам замечания пользователя) + +- [x] 6.1 Пользователь: «когда бабл схлопывается, я вижу, как прямоугольник накладывается на круг, и это выглядит резко». Разбор показал: геометрия математически непрерывна (тело интерполируется в идеальный круг ровно в точке, где радиус скругления равен половине стороны), баг не в этом. Проблема перцептивная: у **дефолтных** токенов `bodyCornerRadius` (16dp) случайно совпадает с `circleSize/2` (16dp) — радиус скругления вообще не меняется всю анимацию, «округление» идёт только за счёт сжатия прямоугольника до размера круга; глаз гораздо чувствительнее к изменению формы именно в последние ~20% анимации, где это сжатие завершается — отсюда ощущение резкости («прямоугольник накладывается на круг»), хотя формально всё непрерывно. + - **Фикс**: скругление углов (и дальних, и ближнего) смешивается с «максимально круглой для текущего размера» формой (`maxSafeCorner` из 5.5) не по самому `progress`, а по `cornerBlend = progress^CORNER_BLEND_EXPONENT` (константа `3f`, `UpsellBubbleShape.kt`) — эта кривая держится ближе к 0 (т.е. ближе к `maxSafeCorner`, «таблетка») почти весь диапазон и лишь у `progress=1` резко подтягивается к 1 (итоговый дизайнерский радиус). На состояние покоя не влияет (`cornerBlend(0)=0`, `cornerBlend(1)=1`, как и раньше). + - Эффект при сворачивании (`progress: 1→0`): бабл почти сразу становится похож на таблетку/капсулу, ещё оставаясь крупным, и лишь затем этот уже округлый силуэт уменьшается до размера кнопки — именно то, что предложил пользователь («плавно превращать бабл в круг до того момента как он накладывается на кнопку»), без полноценной двухфазной анимации (форма и размер по-прежнему управляются одним `expandProgress`, тайминги 300/200мс не менялись). + - Проверено временными тестами, ловящими кадры на 25/50/75/90% времени 200-мс анимации сворачивания (после автосворачивания через `AUTO_COLLAPSE_DELAY_MS`): на 25–50% бабл ещё выглядит как в развёрнутом состоянии (сказывается FastOutSlowInEasing — размер меняется быстрее в начале анимации), на 75% уже отчётливо видна широкая скруглённая «таблетка», на 90% — та же таблетка, но заметно уменьшившаяся к кнопке. Обычные скриншоты (`progress=0` и `progress=1`) не изменились; `detekt`/`spotlessCheck`/тесты — чисто. + - **Этот отчёт снова оказался неполным** — пользователь прислал реальные скриншоты с устройства и указал, что квадратный угол всё ещё виден в начале сворачивания. Разбор показал две отдельные проблемы, которые синтетические Robolectric-кадры на фиксированных % времени не поймали: +- [x] 6.2 **Проблема А — степенная кривая `progress^3` недостаточно резко уходит от 1 сразу после начала сворачивания.** У `p^n` в точке `p=1` конечная производная (`n`), поэтому в первые же миллисекунды после начала схлопывания `cornerBlend` всё ещё близок к 1 (дизайнерский угол) — именно это и видно на реальных кадрах пользователя. Заменено на **рамп с порогом**: `cornerBlend = ((p - CORNER_SHARPEN_THRESHOLD) / (1 - CORNER_SHARPEN_THRESHOLD)).coerceIn(0f, 1f)`, `CORNER_SHARPEN_THRESHOLD = 0.85f` — при ЛЮБОМ `p ≤ 0.85` форма сразу полностью на `maxSafeCorner` (таблетка), «затачивание» до дизайнерского угла происходит линейно только в последних 15% диапазона перед `p=1`. + - **Проблема Б (настоящий баг, не связанный с кривой скругления) — кривая по умолчанию (`FastOutSlowInEasing`, применяется и при сворачивании неявно) имеет НУЛЕВУЮ начальную скорость** (плоский старт, как у стандартного ease-in-out) — `expandProgress` в первые ~30-40% времени 200-мс анимации почти не менялся, из-за чего форма ещё долго выглядела «дизайнерской» (не успевала измениться), а вся видимая трансформация сжималась в оставшуюся часть времени. Заменено в `UpsellBubbleTrigger.kt` на `easing = LinearOutSlowInEasing` (линейный старт, торможение только к концу) — теперь `progress`, а с ним и скругление, начинает меняться с первого же кадра. + - **Проблема В (настоящий баг, найден при диагностике А/Б через более мелкие временные срезы 16/32/64/150/170/200мс, которых не было в прошлом заходе) — вырожденная «шейка» на позднем участке сворачивания.** `neckTopY` (=`bodyRect.bottom`) и `neckBottomY` (=`lerp(circleRect.top, circleRect.top+overlap, p)`) при дефолтных пропорциях меняются местами (`neckTopY > neckBottomY`, т.е. отрицательная высота) при `p ≲ 0.58` — координаты и раньше считались верно для `p=1` и для «средних» кадров вроде 0.7, но диагностика прошлого захода случайно не попадала в проблемную зону (≤0.58), а новый линейный-старта easing стал в ней задерживаться заметно дольше. Union такого перевёрнутого прямоугольника с телом/кругом ломал геометрию — на реальном скриншоте это выглядело как чёткий острый квадрат вместо круга (видно на кадрах 150–200мс: сначала аккуратная «таблетка» на 64мс, затем регресс в квадрат к 150–200мс, и только раздельный `if (p <= 0f) return addOval(...)` спасал самый последний кадр). Исправлено: `buildUpsellBubblePath` теперь явно проверяет `neckTopY >= neckBottomY` и в этом случае возвращает `body ∪ circle` без шейки вообще — в этой зоне тело и круг и так почти совпадают, отдельный мост не нужен. + - Все три фикса подтверждены на настоящем таймлайне сворачивания (временные тесты на 16/32/64/150/170/180/200/250мс от начала 200-мс анимации, после автосворачивания): 16мс — ещё не тронуто (1 кадр, незаметно на глаз), 32-64мс — уже отчётливая широкая «таблетка», 150-200мс — плавно уменьшающийся почти идеальный круг (никакого регресса в квадрат), 250мс (после завершения) — точный круг. Обычные скриншоты (`progress=0`/`1`) не изменились; `detekt`/`spotlessCheck`/тесты по `plasma.homeds.compose` — чисто. Все временные debug-тесты удалены после проверки. +- [x] 6.3 По просьбе пользователя тем же способом (временные тесты на покадровых срезах, не только на состояниях покоя) проверена и анимация РАЗВОРОТА (`progress: 0→1`, 300мс, `FastOutSlowInEasing`) — найдены два реальных бага, оба ранее скрытые тем же способом, что и в 6.1-6.2 (проверялись только состояния покоя, не промежуточные кадры): + - **Баг Г — self-intersection «шип» на стыке ближнего угла с галтелью шейки, теперь в ДВИЖЕНИИ, а не в покое.** После фикса Бага №5 (см. 5.5/6.1) статичное `progress=1` было безопасно (`safeNearCornerPx` учитывал `marginToFillet`), но во ВРЕМЯ анимации (оба направления, поймано на кадре ~180мс разворота) ближний угол блендился между `maxSafeCorner` (без учёта галтели!) и `safeNearCornerPx`, и пока `cornerBlend=0` (весь диапазон `progress ≤ CORNER_SHARPEN_THRESHOLD`, т.е. почти вся анимация) угол становился «максимально круглым для размера», снова пересекаясь с дугой галтели шейки — тот же класс артефакта, что и Баг №5, просто видимый только в движении. Исправлено в `buildUpsellBubblePath`: "круглый" край блендинга для ближнего угла берёт `min(maxSafeCorner, marginToFillet × 0.8)` вместо голого `maxSafeCorner`, но ТОЛЬКО когда шейка (с галтелью) уже включена в Union (`neckIncluded`, см. Баг В/6.2) — если шейки ещё нет (малый `progress`), ограничивать нечем, и угол свободно стремится к `maxSafeCorner`, иначе тело не сходится в правильный круг к `progress→0`. `overlapPx`/`neckTopY`/`neckBottomY`/`neckIncluded` для этого пришлось поднять выше по функции (считались только перед сборкой самой шейки). + - **Баг Д — ~50мс "провал в пустоту" в самом начале разворота.** Локальный кружок в `UpsellBubbleTrigger.kt` гасился (`alpha=0`) СРАЗУ по смене `state` на `Expanded` (синхронно с кликом), а оверлей в `UpsellBubbleHost.kt` регистрируется через `LaunchedEffect`+`snapshotFlow`, реально появляясь на экране на несколько кадров позже (плюс сам `FastOutSlowInEasing` первые кадры даёт почти неразличимо малое значение `progress`) — в эти ~50мс не видно НИ локального кружка, НИ оверлея. Подтверждено на кадрах 0/16/32/40/48мс (везде пусто) и 56/64мс (уже виден оверлей). Первый фикс — плавный кроссфейд локального кружка по `expandProgress` вместо жёсткого переключения по `state` — **дал регресс**, см. 6.4. + - Оба фикса (Г и первая версия Д) подтверждены на полном таймлайне разворота (0/16/32/40/48/56/64/100/150/180/200/230/250/270/290/310мс): не осталось ни пустых кадров, ни артефактов на стыке галтели; итоговое состояние покоя (`progress=1`) не изменилось. `detekt`/`spotlessCheck`/тесты — чисто. Все временные debug-тесты удалены после проверки. +- [x] 6.4 Пользователь прислал 5 кадров реального разворота на устройстве (тёмная тема, карточка "Кухня") с тремя новыми замечаниями — разбор дал ОДНУ регрессию (моя же правка из 6.3) и два реальных, ранее не затронутых дефекта: + - **Регрессия — "обводка расширяется над крестиком".** Кроссфейд локального кружка (фикс Бага Д, см. 6.3) устранял пустое окно, но давал худший артефакт: пока `expandProgress` растёт от 0, локальный (немасштабированный) кружок ЕЩЁ частично виден (alpha>0) ОДНОВРЕМЕННО с оверлеем, который рисует другую геометрию (растущее тело+шейку+иконку) в той же позиции — на реальном устройстве это читалось как отдельный светлый "ободок" поверх формирующегося крестика. Исправлено: локальный кружок вернулся к жёсткому переключению по `state` (как до 6.3), а сам временной разрыв устранён по-другому — `LaunchedEffect` в `UpsellBubbleTrigger.kt` регистрирует оверлей по условию `state == Expanded || expandProgress > 0f || dismissAlpha < 1f` (было только `expandProgress > 0f || dismissAlpha < 1f`) — ловит самый первый кадр разворота, где `Animatable.animateTo` ещё не успел сдвинуть значение с 0f. Остаточный разрыв сократился с ~50мс до ~2 кадров (32мс) и никогда не даёт двойного рендера — проверено на 0/16/32/48мс (пусто→пусто→оверлей, без наложения) и на хвосте сворачивания 195/199/205мс (чисто, без двойного кружка). + - **"Вращение" при морфинге звезда→крестик — не баг геометрии, а иллюзия кроссфейда.** У 4-конечной звезды (`ic_star_four_outline_16`) и крестика (`ic_close_24`) лучи стоят под углом 45° друг к другу; пока оба видны одновременно (alpha-кроссфейд в `UpsellBubbleTriggerIcon.kt`), они складываются в 8-конечную "снежинку" — при быстром просмотре читается как вращение. Подтверждено покадровым скриншотом на середине морфинга. Исправлено: звезда доворачивается на 45° синхронно с `morphProgress` (`rotationZ = 45f * p`) — на `p=0` (звезда непрозрачна, состояние покоя) поворота нет, а к моменту заметного поворота звезда уже почти прозрачна. Проверено на 60/90/130мс — лучи звезды и крестика теперь совпадают по направлению вместо появления лишних лучей. + - **Свёрнутая кнопка не соответствовала макету — по правке пользователя.** Была реализована как круг (`CircleShape`, залитый `bodyBrush`) с отдельной белой звёздочкой-иконкой (`ic_star_four_outline_16`) внутри; должна быть САМА силуэтом 4-конечной звезды. Найден готовый закрашенный силуэт той же геометрии в дизайн-системе — `ic_star_four_fill_16` (`sdds-core/icons`) — вместо новой зависимости (`androidx.graphics:graphics-shapes`, которой в проекте нет) или переписывания Union-геометрии `buildUpsellBubblePath` под звезду (что потребовалось бы, если бы звезда была нужна и во ВРЕМЯ разворота/на развёрнутом якоре — но пользователь имел в виду только статичное свёрнутое состояние, оверлей с круглым якорем+крестиком при разворачивании и в развёрнутом виде остался как есть, вопросов не вызвал). Новый файл `UpsellBubbleStarFourShape.kt` — параметрический Compose `Shape`, путь `ic_star_four_fill_16` вручную перенесён из vector XML (abs-координаты вычислены из relative SVG-команд) и масштабируется под фактический `circleSize`. В `UpsellBubbleTrigger.kt`: `.background(bodyBrush, CircleShape)` → `.background(bodyBrush, UpsellBubbleStarFourShape)`, отдельная `UpsellBubbleTriggerIcon` внутри локального круга убрана целиком (силуэт уже несёт значение "звезда", отдельная белая иконка поверх избыточна) — `iconBrush`/`Alignment`/`CircleShape` импорты в файле стали не нужны, удалены. + - Все три фикса подтверждены визуально (`testCollapsedShowsStarIcon` пересобран и совпадает с геометрией `ic_star_four_fill_16`; ручная проверка стыка звезда/оверлей на 0/16/32мс — без двойного рендера). `detekt`/`spotlessCheck`/тесты по `plasma.homeds.compose` — чисто. Все временные debug-тесты удалены после проверки. + +## 6. Сэндбокс и интеграционные фикстуры + +- [x] 6.1 Добавить `UpsellBubbleStory.kt` в `tokens/plasma.homeds.compose/app/.../sandbox/strories/upsellbubble/` — ручной показ/скрытие нового предложения (`hasNewOffer` в `@StoryUiState`), обёрнуто в `UpsellBubbleHost`; KSP сгенерировал `HomeDsUpsellBubbleUiState{PropertiesProducer,Transformer}` (проверено сборкой `:app:compileDebugKotlin`) +- [x] 6.2 Добавить `PlasmaHomedsUpsellBubbleVariationsCompose.kt` + `UpsellBubbleKey`/регистрацию в `PlasmaHomedsComposeCustomComponents.kt` (`tokens/plasma.homeds.compose/integration/.../custom/`) — единственная вариация `UpsellBubble.Default` (аналог `PlasmaHomedsNumberPanelVariationsCompose.kt`) + +## 7. Документация + +- [x] 7.1 Добавить `UpsellBubbleSamples.kt` в `tokens/plasma.homeds.compose/docs/.../samples/` (аналог `NumberPanelSamples.kt`) — проверено сборкой `:docs:compileDebugKotlin` +- [x] 7.2 Добавить `UpsellBubbleUsage.md` в `docs/override-docs/docs/components/` с описанием двухчастного API и примером интеграции в сетку (аналог `NumberPanelUsage.md`) + +## 8. Тесты и валидация + +- [x] 8.1 Добавить скриншот-тесты на переходы стейт-машины в `ComposeUpsellBubbleScreenshotTest.kt` (Collapsed/Expanded/Hidden — 3 сценария × 2 темы, `mainClock.autoAdvance=false`+`advanceTimeBy` для детерминированной поимки Expanded-кадра без прогона 5-секундного таймаута); прогнано `testDebugUnitTest` — 8/8 passed, 0 failures. Golden-изображения сгенерированы (`-Proborazzi.test.record=true`) и **визуально просмотрены** — это и вскрыло оба бага, описанных в 4.4 и 5.3 (без просмотра пикселей их не было видно: тесты проходили «зелёными» и с белым-на-белом, и с незапущенным морфингом). После фиксов повторный прогон записи подтвердил: зелёное тело, читаемый белый текст, звёздочка в Collapsed, крестик в Expanded. Таймаут-автосворачивание и клик-по-крестику отдельным скриншотом не покрыты (не блокирует — сама stateflow-логика упражняется кодом) +- [x] 8.2 Добавить `testExpandedBubbleOverlapsNarrowNeighborCards` — триггер в карточке 90.dp (у́же тела бабла), проверяет рендер через `UpsellBubbleHost` без клипа по границе своей карточки; тест зелёный +- [x] 8.3 Прогнать `./gradlew tokens:plasma.homeds.compose:test` — BUILD SUCCESSFUL +- [x] 8.4 Прогнать `./gradlew tokens:plasma.homeds.compose:detekt` — нашёл 2 реальных замечания (cyclomatic complexity 16/15 в `UpsellBubbleTrigger`, неиспользуемый импорт `Brush` в `UpsellBubbleHost.kt`), оба исправлены (`@Suppress("CyclomaticComplexMethod")` по прецеденту `AiInput.kt`/`BasePopover.kt`; импорт удалён) — повторный прогон чистый +- [x] 8.5 Прогнать `./gradlew tokens:plasma.homeds.compose:spotlessApply` — поправил порядок импортов в `UpsellBubbleSamples.kt` +- [x] 8.6 Собрать `./gradlew tokens:plasma.homeds.compose:app:assembleDebug` — BUILD SUCCESSFUL, APK собирается целиком (dex/packaging). Ручная проверка на эмуляторе/устройстве не выполнялась — в этой сессии нет доступа к Android-эмулятору (см. 3.3) + +## 9. Правки по актуальным Figma-макетам триггера + +Пользователь прислал ссылки на обновлённые макеты (`node 3218:3419` — тело бабла в обоих направлениях, `node 3219:1855`/`3219:1760` — иконка и «Trigger»-бейдж) и видеозапись реального разворота/сворачивания, указав, что реализация разошлась с макетом по пяти пунктам. Экран записан пользователем в `~/Desktop`; для покадрового разбора видео извлекались кадры через `AVAssetImageGenerator` (Swift-скрипт, т.к. `ffmpeg`/`cv2` в окружении нет) — обнаружено, что реальное имя файла содержит неразрывные пробелы (`\xa0`), из-за чего прямой путь через `ls`/`cat` не резолвился («No such file or directory»), понадобилось найти файл через `os.listdir` и скопировать по нормальному пути. + +- [x] 9.1 **Разбор Figma (`get_design_context`/`get_screenshot`/`get_variable_defs`, скачаны реальные SVG-ассеты через `curl`)** дал точную структуру, ранее реализованную неверно: + - Узел `Trigger` (26×26, внутри круга-якоря 32dp) — ДВА независимых слоя: тонкая полупрозрачная обводка-звезда `«Star 3»` (`stroke #F9F9F9 12%`, без своей заливки — токен `Outlines/Default/General/Transparent/Primary` = `outlineOnDarkTransparentPrimary`) и белая иконка поверх. Сама заливка круга-якоря (`Union`, `fill #1A9E32` = `Surfaces/Default/Status/Solid/Positive`) — ОБЫЧНЫЙ круг, не звезда. + - Путь иконки `24/Toggle/StarFourFill` из макета совпадает (с точностью до масштаба 24/16) с уже существующим в репозитории `ic_star_four_fill_24.xml` — подтверждено численно (первая точка пути `10.357,3.273` = `6.9049,2.18286 × 1.5`), новый ассет заводить не пришлось. + - Дальние углы тела (`Union`, `node 3219:1747`) по бэзье-дельтам точных дуг — радиус **12dp** (было `16dp`), ближний к шейке угол — **6dp** (было `4dp`, точный замер по бэзье ≈5.5dp, округлено). +- [x] 9.2 **По правке пользователя (п.1 — «белый крест на кнопке это иконка из библиотеки, найди сам»):** в `UpsellBubbleTriggerIcon.kt` звёздочка-иконка заменена с `ic_star_four_outline_16` (контур, не тот вариант) на `ic_star_four_fill_16` (закраска, соответствует названию `StarFourFill` из Figma). +- [x] 9.3 **По правке п.2 («бордер поворачивается вместе с фоном, иконка стоит на месте, крестик появляется плавно без поворота»):** ранее самой иконке (звезде) было добавлено вращение на 45° как обходной фикс иллюзии вращения при кроссфейде (см. 6.4) — по актуальным макетам это оказалось НЕВЕРНО: вращаться должна декоративная обводка-«мерцание», а не иконка. Убрано вращение со звезды в `UpsellBubbleTriggerIcon.kt`; добавлен отдельный слой-обводка (`Modifier.border(1.dp, borderBrush, UpsellBubbleStarFourShape)`, переиспользует уже существующий `UpsellBubbleStarFourShape.kt`, но теперь как контур, а не заливка), поворачивающийся через `graphicsLayer { rotationZ = -45f × eased(morphProgress) }` — 0° в свёрнутом состоянии, -45° в развёрнутом (тот же угол подтверждён в обоих Figma-узлах `Trigger`, для `Align=Start` и `Align=End` — без зеркалирования). Иконки (звезда/крестик) — только альфа+скейл, без поворота. +- [x] 9.4 **По правке п.3 («вся анимация, кроме альфы — с 10% овершутом»):** добавлен `UpsellBubbleOvershootEasing.kt` — та же формула, что у `OvershootInterpolator`/Robert Penner's `backOut` с тензией `1.70158` (классическое значение, даёт ровно ~10% перелёта). Применена как `easing` в `tween(...)` для `expandProgress.animateTo` (рост/схлопывание тела, `UpsellBubbleTrigger.kt`) и как обычная функция `.transform(p)` для пересчёта уже линейного `morphProgress` в овершут-версию ТОЛЬКО при вычислении масштаба иконок и угла поворота обводки (`UpsellBubbleTriggerIcon.kt`) — сама альфа (и `iconMorph`, и `expandProgress` через `coerceIn(0f,1f)` на альфе контент-слота) остаётся линейной без овершута. Геометрия тела (`buildUpsellBubblePath`) уже клэмпит входной `progress` в `0f..1f` (см. 4.1) — перелёт `expandProgress` за эти границы (кратковременный уход в отрицательные значения при овершуте на сворачивании) безопасен, форма просто держится в уже достигнутом крайнем состоянии лишний кадр-другой. +- [x] 9.5 **По правке п.4 («сверь размеры с макетами»):** `bodyCornerRadius`/`bodyNearCornerRadius` (билдер и вариация `Default`) обновлены на замеренные 12dp/6dp (см. 9.1); `circleSize=32dp` подтверждён точным совпадением с Figma, не менялся. +- [x] 9.6 **По правке п.5 («в настройке стиля не используем все параметры — надо явно их указать в Default»):** `WrapperUpsellBubbleDefault` (`UpsellBubbleStyles.kt`) дополнен явным блоком `.dimensions { ... }` (все 9 размерностей перечислены явно, не наследуются молча из билдера) и `borderColor(...)` в `.colors { ... }`. +- [x] 9.7 Добавлена новая размерность цвета `UpsellBubbleColors.borderBrush` (интерфейс/билдер/`DefaultUpsellBubbleColors`, дефолт `Color.White.copy(alpha=0.12f)` — совпадает с сырым значением из Figma на случай кастомного стиля без явной `borderColor`). +- [x] 9.8 **Побочный эффект фикса 9.3, обнаруженный при разборе:** локальный свёрнутый триггер (`UpsellBubbleTrigger.kt`) ранее заливался ПОЛНОСТЬЮ силуэтом звезды (`UpsellBubbleStarFourShape` как fill, см. 6.4) — по факту разбора Figma (9.1) это было неверно: заливка круга-якоря везде обычный круг, силуэт звезды даёт только обводка. Заменено обратно на `CircleShape`-заливку + вложенный `UpsellBubbleTriggerIcon(morphProgress = { 0f }, ...)` (тот же узел, что рисует `UpsellBubbleHost` в развёрнутом состоянии, здесь просто в состоянии покоя) — заодно устранило скрытое несоответствие форм между локальным кружком (звезда) и оверлеем (круг) в самый первый момент разворота, из-за которого в первые ~1-2 кадра форма визуально «дёргалась» (не было отдельно диагностировано как баг, но подтверждено устранённым при повторной покадровой проверке, см. 9.9). +- [x] 9.9 **Проверка:** временные покадровые тесты (16/33/50/66/83/100/130/160/200/260/320/400мс разворота; 16/33/50/66/83/100/130/160/200/260мс сворачивания, после ожидания `AUTO_COLLAPSE_DELAY_MS`) — обводка плавно поворачивается через промежуточные углы (звезда → диамант/X-ориентация), иконки кроссфейдятся без поворота, овершут заметен на размере тела у самого конца анимации, без острых углов/self-intersection на всём диапазоне. Обычные скриншоты (`testCollapsedShowsStarIcon`, `testExpandedShowsBubbleBody`, `testExpandedBubbleOverlapsNarrowNeighborCards`) пересняты; `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest` — чисто. Полный прогон `testDebugUnitTest` без `--tests`-фильтра с `-Proborazzi.test.record=true` **непреднамеренно перезаписал ~80 скриншотов других компонентов** (шум рендеринга окружения, не связано с этой правкой) — откачены через `git checkout` обратно к закоммиченной версии; впредь для этого модуля перезаписывать golden-изображения только точечным `--tests`-фильтром. Все временные debug-тесты и их артефакты удалены после проверки. + +## 10. Правки размеров по итогам просмотра скриншотов из раздела 9 + +Пользователь посмотрел присланные скриншоты и указал на четыре несоответствия макетам. + +- [x] 10.1 **«Размер иконки ты выбрал 16, а надо 24»**: `UpsellBubbleDimensions.iconSize` (билдер и вариация `Default`) — `16.dp` → `24.dp`. +- [x] 10.2 **«Размер свёрнутой кнопки — 32 — это так сейчас?»**: подтверждено — да, `circleSize` не менялся и остаётся `32.dp`, точно совпадает с диаметром круга-якоря в Figma-`Union` (см. 9.5). +- [x] 10.3 **«Толщина спиц звезды должна быть больше, она должна быть более пухлой»**: `UpsellBubbleStarFourShape.kt` переписан с буквальной геометрии иконки (`cubicTo`/`lineTo` по координатам `ic_star_four_fill_16`) на параметрическую полярную кривую `innerRadius + (outerRadius-innerRadius)·cos(2φ)^exponent`, строится многоугольником (128 точек) вместо явных кривых Безье. + - Первая попытка (`INNER_RADIUS_RATIO=0.5`, `PUFFINESS_EXPONENT=0.4`, то есть <1) дала **перебор** — силуэт превратился в четырёхлистный клевер с полностью скруглёнными лепестками без выраженных остриёв (замечено при собственной визуальной проверке скриншота ДО показа пользователю — при `exponent<1` радиус слишком долго держится у `outerRadius` и падает только у самой впадины между лучами). + - Исправлено на `INNER_RADIUS_RATIO=0.3`, `PUFFINESS_EXPONENT=1.8` (>1, острее у вершины) — силуэт снова однозначно читается как 4-конечная звезда, но заметно толще/пухлее оригинальной тонкой иконки. Проверено покадровым zoom-кропом (PIL, 2× апскейл Lanczos) до показа пользователю. +- [x] 10.4 **«Шейка бабла очень длинная, это совсем не похоже на макеты»**: пересчитан реальный зазор по бэзье-координатам того же `Union`-узла (`node 3219:1747`, см. 9.1) — край тела `x=36`, правый край круга-якоря `x=32` (центр 16 + радиус 16), реальный зазор всего **4dp**. `UpsellBubbleDimensions.neckLength` (билдер и `Default`) — `20.dp` → `4.dp` (было производной оценкой без точного замера, см. 5.4). +- [x] 10.5 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто (перезапись golden-изображений — точечным `--tests`-фильтром, без повторения инцидента из 9.9). Обычные скриншоты пересняты и визуально проверены (в т.ч. промежуточная неудачная версия «клевера» — до отправки пользователю, не после). + +## 11. Пухлая звезда — от параметрической формулы к точной геометрии Figma + +Присланный пользователем скриншот раздела 10 (звезда после тюнинга параметрической кривой) пользователь оценил как «выглядит ещё хуже» и прямо указал ссылку на макет (`node-id=3218-3418`, тот же файл) с просьбой перенести контур оттуда, а не подбирать формулу на глаз. + +- [x] 11.1 Разобран узел `BubbleHorizontal`/`Trigger` (`node 3219:1853`, `get_design_context` + скачан реальный SVG-ассет узла через `curl`) — это ТОЧНО тот же элемент, что стоит в покое в свёрнутом состоянии (не «Union»-круг из раздела 9, который относится к геометрии РАСТУЩЕГО тела, а отдельный самостоятельный ассет для бейджа). Обнаружено: + - Путь заливки (`fill #1A9E32`) — 14-сегментная кривая (`moveTo`+7×`cubicTo`+7×`lineTo` по кругу) в системе координат ровно 32×32, БЕЗ уменьшения — то есть при `circleSize=32dp` этот путь используется 1:1, не в масштабе 26/32 (эта меньшая доля — только у бейджа на углу РАЗВЁРНУТОГО тела, другой инстанс того же компонента, см. 9.1). + - Обводка (`stroke #F9F9F9 12%`) — почти тот же путь с отличием координат на ~1% (похоже на артефакт Figma-выравнивания стро́ука "внутрь"), визуально неотличимо от заливки того же размера — реализовано одним путём на оба назначения (как и раньше). + - Иконка внутри — тот же `ic_star_four_fill`, но путь буквально совпадает с 16-юнитным вариантом (`ic_star_four_fill_16`), просто сдвинутым на (8,8) для центрирования в 32×32 — **без масштабирования до 24dp**, вопреки словесной правке пользователя из раздела 10 (см. 11.3). +- [x] 11.2 `UpsellBubbleStarFourShape.kt` переписан ещё раз — параметрическая полярная формула (раздел 10) заменена на буквальный перенос этого пути (`moveTo`/`cubicTo`/`lineTo` по абсолютным координатам, масштаб `size.width / 32f`, тот же приём, что и для иконки). Вершины лучей в оригинале намеренно чуть выходят за номинальный квадрат 32×32 (у Figma-SVG `overflow="visible"`) — перенесено как есть, это часть дизайна. +- [x] 11.3 **Ретроспективно исправлена ошибка раздела 10.1** — `iconSize` (билдер и `Default`) возвращён с `24.dp` обратно на `16.dp`: словесная правка «иконка должна быть 24» интерпретировалась как размер отрисовки, но точный пиксельный разбор (11.1) показал, что 24 в имени `24/Toggle/StarFourFill`, судя по всему, относится к номиналу самого компонента иконки в библиотеке, а не к размеру рендера в этом бейдже — фактически иконка здесь всегда была 16dp. `UPSELL_BUBBLE_BADGE_BORDER_SCALE` возвращена с временных `30/32` (раздел 9.3, попытка "раздвинуть" обводку от иконки) на измеренные по Figma `26/32` — та теснота была следствием неверной формы/размера иконки, а не доли обводки для бейджа на углу тела; в свёрнутом состоянии в покое обводка теперь равна `circleSize` напрямую (без доли вообще, см. 11.1). +- [x] 11.4 **Проверка методом прямого сравнения с оригиналом, а не на глаз**: локальный HTTP-сервер (`python3 -m http.server`) поднят над scratchpad-директорией, скачанный SVG-ассет узла открыт в Browser-панели как веб-страница (просмотр `file://` не поддерживает скриншоты/zoom в этом окружении) и заскриншочен при увеличении до 600×600 — получен рендер эталонной фигуры независимо от Compose-транскрипции. Сравнён бок о бок с пересобранным `testCollapsedShowsStarIcon` (кроп+2× Lanczos-апскейл, PIL) — силуэты совпадают пиксель-в-пиксель по форме (4 скруглённых лепестка с более узкими "перемычками" между ними — то, что на предыдущих скриншотах читалось как перебор в сторону "клевера", оказалось точной формой самого макета, не артефактом транскрипции). Единственное оставшееся отличие — у оригинала на заливке есть мягкий радиальный блик/inner-shadow (`filter0_i_*` в SVG-defs, светлее сверху-слева), в реализации заливка плоская; не воспроизведён (пользователь не просил, эффект тонкий) — можно добавить отдельным пунктом, если понадобится точнее. + +## 12. Внутренняя тень (inner shadow) на кнопке и на теле бабла + +Пользователь прислал скриншот панели Figma "Shadows and blurs" с параметрами эффекта (Inner shadow, X=0, Y=0, Blur=8, Spread=0, `#D0FF77` 80%) и уточнил, что этот же эффект — не только на кнопке-триггере (замеченный в 11.4 «блик» на заливке), но и на всём теле развёрнутого бабла. + +- [x] 12.1 Скачанные ранее (раздел 9/11) сырые SVG-ассеты узлов "Star 3" (кнопка) и "Union" (тело) оба содержат идентичный SVG-фильтр (`filter0_i_*`: `feGaussianBlur stdDeviation="4"` → `feComposite operator="arithmetic" k2=-1 k3=1` → `feColorMatrix` на цвет `0.815686/1/0.466667` при альфе `0.8`) — подтверждает слова пользователя буквально: один и тот же inner shadow с одними и теми же параметрами на обеих фигурах. +- [x] 12.2 Добавлен `UpsellBubbleInnerGlow.kt` — переиспользуемая функция `DrawScope.drawUpsellBubbleInnerGlow(path, color, blurRadiusPx)`: в Compose `DrawScope` нет декларативного inner-shadow, поэтому эффект собран вручную стандартным Android-приёмом — обвести контур `path` толстым полупрозрачным штрихом с `android.graphics.BlurMaskFilter` (через `drawIntoCanvas`/нативный `Paint`, т.к. `BlurMaskFilter` не выражается через обычный `DrawScope.drawPath`) и обрезать (`clipPath`) всё, что рисуется СНАРУЖИ фигуры — остаётся мягкое свечение по внутреннему краю. Это приближение к точной формуле Figma-фильтра (`hardAlpha − blur(hardAlpha)`, маска-based), не побитовая копия — воспроизвести формулу дословно потребовало бы рендера в оффскрин-альфа-буфер, которого DrawScope не даёт напрямую. +- [x] 12.3 Подключено в двух местах: заливка кнопки-триггера в покое (`UpsellBubbleTrigger.kt` — `Modifier.background(...)` заменён на `drawWithCache`/`onDrawBehind`, т.к. `.background()` не даёт дорисовать поверх второй слой) и заливка тела бабла (`UpsellBubbleHost.kt`, сразу после `drawPath(path, bodyBrush)`) — с одинаковыми параметрами цвета/радиуса в обоих местах (общие константы `UPSELL_BUBBLE_INNER_GLOW_COLOR`/`UPSELL_BUBBLE_INNER_GLOW_BLUR_DP`). +- [x] 12.4 **Радиус размытия — 4dp, а не показанные в Figma-панели «8»**: первая попытка (8dp, буквально по UI-значению) дала эффект вдвое сильнее нужного — заливка «вымывалась» почти целиком в цвет тени вместо мягкого свечения с явно видимым базовым зелёным (замечено собственной проверкой ДО показа пользователю, сравнением с независимо отрендеренным эталонным SVG). Причина — в самом SVG-фильтре (12.1) реальный `feGaussianBlur stdDeviation="4"`: показанное в UI Figma число "8" — это общепринятое соглашение Figma UI-blur = 2×sigma, не сам радиус для рендера. С 4dp результат заметно ближе к эталону при прямом сравнении (кроп+Lanczos, тот же метод, что в 11.4). +- [x] 12.5 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, посторонние скриншоты не тронуты (точечный `--tests`-фильтр). Обычные скриншоты (кнопка в покое и развёрнутое тело) пересняты и визуально проверены на обоих местах применения эффекта. +- [x] 12.6 **Пользователь на присланном скриншоте**: «засветка слишком интенсивная, ты учёл альфу 0.8?». Проверено предметно, не на глаз — сэмплирован самый яркий зелёный пиксель на пересобранном скриншоте: `(170,233,117)`, расчётная смесь 80%-альфа `#D0FF77` поверх базового `#1A9E32` — `(172,236,105)`, отличие в пределах погрешности растеризации. Т.е. альфа 0.8 применялась ВЕРНО в каждой отдельной точке — проблема была не в значении альфы, а в ПЛОЩАДИ, на которой достигается этот пик: + - Старая ширина штриха (`2×blurRadiusPx`, т.е. центр линии контура находится на расстоянии `blurRadiusPx` от обоих краёв штриха) физически не давала блюру дотянуться до середины штриха — середина оставалась плоским "плато" полной интенсивности (0.8-альфа сплошняком), а не гладким спадом, отсюда ощущение "слишком интенсивно", хотя формула смешения была верна в каждой точке. + - Исправлено: ширина штриха уменьшена до `1×blurRadiusPx` (`UpsellBubbleInnerGlow.kt`) — при такой ширине весь штрих физически укладывается в зону действия блюра, плато исчезает, профиль становится гладким гребнем, спадающим в обе стороны — заметно ближе к эталонному Figma-рендеру при прямом сравнении (тот же метод, что в 11.4/12.4). + - Пересобраны скриншоты кнопки и тела бабла, `detekt`/`spotlessCheck`/тесты — чисто. +- [x] 12.7 **Пользователь прислал скриншот с эмулятора — засветка там ВСЁ РАВНО заметно сильнее**, чем на пересобранном (12.6) Robolectric-скриншоте, хотя код не менялся между 12.6 и этим сообщением. Разбор показал корневую причину, не связанную с шириной штриха: `android.graphics.BlurMaskFilter` (техника из 12.2/12.6) корректно работает только при ПРОГРАММНОМ (software) рендере слоя — известное ограничение Android. Robolectric в `GraphicsMode.NATIVE` рендерит программным Skia, поэтому маска размытия там честно смягчала штрих. На реальном устройстве/эмуляторе путь отрисовки Compose аппаратно ускорен — `BlurMaskFilter` в этом случае молча ИГНОРИРУЕТСЯ, и штрих рисуется СПЛОШНЫМ непрозрачным (в пределах альфы Paint) на всю свою ширину, без всякого размытия — отсюда и разница между тестами и реальным устройством, и ощущение «слишком сильная засветка» именно там, где раньше не проверялось (весь предыдущий цикл 12.1–12.6 проверялся только скриншот-тестами). + - Исправлено полной заменой техники — `BlurMaskFilter`/`drawIntoCanvas`/нативный `Paint` убраны совсем, вместо них `UpsellBubbleInnerGlow.kt` рисует несколько (8) концентрических полупрозрачных штрихов вдоль контура нарастающей ширины (от тонкого до `2×blurRadiusPx`) с одинаковой малой альфой каждый — точка на самом контуре покрыта всеми 8 слоями (альфа накапливается до максимума через обычный alpha-blending), точка на границе радиуса — только самым широким (минимальная альфа), что даёт тот же плавный затухающий профиль, но БЕЗ единого вызова `MaskFilter` — обычный `DrawScope.drawPath(..., style = Stroke(...))` рендерится идентично что программно (Robolectric), что аппаратно (реальное устройство), этот класс расхождений в принципе не может возникнуть. + - Проверено тем же методом прямого сравнения с эталонным SVG-рендером (11.4/12.4/12.6) — результат на скриншот-тесте визуально не отличим от версии до этого фикса (значит корректность самой формы/интенсивности не пострадала при смене техники), а на реальном устройстве проверить в рамках этой сессии возможности нет (нет доступа к Android-эмулятору, см. 3.3) — по структурной причине (полное устранение `MaskFilter`) результат должен совпасть с Robolectric-рендером и на устройстве тоже; окончательное подтверждение — за пользователем. + - `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, посторонние скриншоты не тронуты. +- [x] 12.8 **Пользователь на слоистой версии (12.7)**: «стало грубее и ещё хуже, видно на скриншотах» — валидно: 8 дискретных концентрических колец давали заметную полосатость (не настоящее размытие, а его грубая аппроксимация), это подтверждено при повторном сравнении с эталоном. Пользователь дал прямое указание: реализовать через `Modifier.shadow`/`ShadowAppearance` — тот же механизм, что уже используется в теме для теневых токенов (см. `TooltipStyle.shadow`), и завести сам токен тени в стиле компонента. + - Разбор существующего механизма (`sdds-core/uikit-compose/.../shadow/Shadows.kt`, `Shadows.android.kt`) показал: `Modifier.shadow(appearance, shape)` рисует `android.graphics.Paint().apply { setShadowLayer(radius, dx, dy, color) }` — принципиально ДРУГОЙ нативный API, чем `BlurMaskFilter` (12.1–12.7): `Paint.setShadowLayer` официально поддерживается при аппаратном ускорении начиная с API 28 (в реализации есть явный чек `shouldDrawFallbackShadow() = SDK_INT < P`, ниже 28 — фолбэк на elevation-тень) — то самое ограничение, из-за которого не работал `BlurMaskFilter`, здесь в принципе не воспроизводится. + - `ShadowAppearance`/`ShadowLayer` — механизм ТОЛЬКО для обычных (внешних) drop-теней, нет режима "внутрь" (спред только раздвигает форму-каста́тель наружу/внутрь через `canvas.scale`, а сама тень рисуется `onDrawBehind`, то есть позади остального контента модификатора). Имитация inner shadow: `.background(fill, shape)` → `.clip(shape)` → `.shadow(appearance, shape)` с ОТРИЦАТЕЛЬНЫМ `spreadRadius` — тень-каста́тель "утоплен" внутрь фигуры, `clip` до него отсекает всё, что размытие рисует за пределами истинного контура, `.shadow` идёт ПОСЛЕ `.background` в цепочке модификаторов (значит рисуется поверх заливки, а не под ней). + - Для тела бабла (`UpsellBubbleHost.kt`) форма зависит от анимируемого `entry.expandProgress()`, а `.clip()`/`.shadow()` принимают `Shape`, не готовый `Path` — заведён приватный `UpsellBubblePathShape`, оборачивающий `buildUpsellBubblePath(...)` внутри `createOutline()`; т.к. `expandProgress()` читается именно там, Compose отслеживает Snapshot-изменение на каждый кадр так же, как раньше отслеживал внутри `onDrawBehind` — устаревшего кадра не возникает. + - `UpsellBubbleInnerGlow.kt`/`UpsellBubbleInnerGlowPainter.kt` (обе предыдущие версии, 12.2–12.7) удалены целиком — рендер полностью переведён на встроенный механизм. + - **Токен тени добавлен в стиль** — `UpsellBubbleStyle.shadow: ShadowAppearance` (по образцу `TooltipStyle.shadow`, поле верхнего уровня, не внутри `colors`/`dimensions`), билдер-метод `.shadow(appearance)`, дефолт `ShadowAppearance()` (без слоёв) в билдере. Готового именованного `PlasmaHomeDsTheme.shadows.*`-токена под цвет `#D0FF77` не нашлось (все — чёрные/серые drop-тени), поэтому вариация `Default` (`UpsellBubbleStyles.kt`) задаёт его сырым значением: `ShadowLayer(color = Color(0xFFD0FF77).copy(alpha=0.8f), offset = DpOffset.Zero, spreadRadius = (-4).dp, blurRadius = 4.dp)`. + - Проверено тем же методом прямого сравнения с эталонным SVG-рендером (11.4/12.4/12.6) — гладкий радиальный профиль без полос, ближе к оригиналу, чем любая из предыдущих версий; т.к. Robolectric-фикстура использует `SDK_NUMBER=33` (>28), тест реально проходит через тот же `setShadowLayer`-путь, что и настоящее устройство — в отличие от `BlurMaskFilter`-версий (12.1–12.6), здесь по конструкции не должно быть расхождения между тестами и реальным рендером. + - `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто; дополнительно собраны зависимые модули `docs`/`app`/`integration` (используют `UpsellBubbleStyle.builder()` — новое обязательное поле `shadow` через билдер не ломает существующие вызовы). Посторонние скриншоты не тронуты. +- [x] 12.9 **Пользователь на `Modifier.shadow`-версии (12.8)**: «стало ещё намного хуже» — результат на реальном устройстве оказался заметно хуже, чем на Robolectric-скриншоте (имитация inner shadow отрицательным `spreadRadius` у внешней тени — приём рабочий по конструкции, но визуально не совпал с ожиданием, несмотря на теоретически более безопасный нативный API). Отдельно выяснилось, что версия из 12.7 (`Bitmap`/`BlurMaskFilter`, рассчитанная сразу после откровения про аппаратное ускорение) так и не была показана пользователю — сообщение про `Modifier.shadow` пришло раньше, чем успел отправить скриншот; отправлен отдельно, пользователь попросил откатить именно к ней. + - Полный откат кода (не только показ картинки): `UpsellBubbleInnerGlowPainter.kt` воссоздан (техника из 12.7 — `BlurMaskFilter` на принудительно программном `Bitmap`/`Canvas`, ширина штриха `1×blurRadiusPx`), `UpsellBubbleTrigger.kt`/`UpsellBubbleHost.kt` — `.clip()`/`.shadow()`/`UpsellBubblePathShape` убраны, отрисовка снова вручную в `drawWithCache`/`onDrawBehind`. `UpsellBubbleStyle.shadow: ShadowAppearance` убран из публичного API стиля целиком (билдер, `DefaultUpsellBubbleStyle`, вариация `Default`) — раз механизм не используется для рендера, оставлять поле было бы мёртвым/вводящим в заблуждение API. + - Пересобранные скриншоты сверены побайтово-визуально с тем, что не было показано в 12.7 — совпадают. + - **Финальная точная настройка альфы — пользователем напрямую в коде**: `UPSELL_BUBBLE_INNER_GLOW_COLOR` альфа `0.8f → 0.6f` (правка внесена пользователем в файл вручную, не через меня) — на глаз ближе к Figma-макету при этой конкретной технике рендера, чем формально заявленные в панели Figma 80%. Оставлено как есть по прямому указанию пользователя; KDoc обновлён, чтобы явно фиксировать расхождение с формальной Figma-спекой и не создавать впечатление опечатки при будущих правках. + - `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто; зависимые модули `docs`/`app`/`integration` пересобраны — ничего не сломалось от удаления `shadow` из стиля. Посторонние скриншоты не тронуты. + +## 13. Овершут не был виден нигде — два реальных бага в реализации + +Пользователь: «не вижу овершут нигде, хотя он должен быть и по размерам бабла при раскрытии, и в угле поворота креста при схлопывании». Разбор нашёл ДВЕ независимые причины — по одной на каждый случай, обе подтверждены не на глаз, а прямым логированием фактических значений внутри анимации (`println` в `onDrawBehind`/`graphicsLayer`, временный тест с покадровым `mainClock.advanceTimeBy`, значения сверены по времени). + +- [x] 13.1 **Овершут угла поворота (схлопывание)** — причина: `UpsellBubbleTriggerIcon.kt` применял `UpsellBubbleOvershootEasing.transform()` ПОСТФАКТУМ поверх `iconMorph.value` — значения, которое сама `Animatable` уже сгладила ДЕФОЛТНЫМ (не-овершут) `easing` в `tween(ICON_MORPH_DURATION_MS)` (см. 9.4). Применение овершут-формулы к уже сглаженному значению давало едва заметный перелёт у самого хвоста, а не настоящий "проскочили мимо цели и вернулись". + - Исправлено: заведён отдельный `Animatable` — `iconShapeProgress` (`UpsellBubbleTrigger.kt`), у которого овершут-кривая стоит НЕПОСРЕДСТВЕННО в его собственном `tween(ICON_MORPH_DURATION_MS, easing = UpsellBubbleOvershootEasing)`, параллельно с `iconMorph` (который остался линейным и управляет только alpha). `UpsellBubbleTriggerIcon` теперь принимает `alphaProgress`/`shapeProgress` раздельно (было — один `morphProgress`); поворот рамки и масштаб иконок читают `shapeProgress()` напрямую, без повторного `.transform()`. `UpsellBubbleOverlayEntry` дополнен полем `iconShapeProgress`. + - Подтверждено логированием: `rotationZ` на пике реально проскакивает цель — `+4.42°` мимо `0°` при схлопывании и `-49.42°` мимо `-45°` при раскрытии (тензия 1.70158 даёт ровно ~10% перелёта, как и было задумано в 9.4) — на реальном пике время не совпадало с координатами покадрового скриншот-теста (пик у угла — на 100мс из 175, а не там, где сэмплировались кадры ранее), из-за чего эффект и не был замечен ни автором, ни пользователем. +- [x] 13.2 **Овершут размера тела (раскрытие)** — причина другая и более фундаментальная: `buildUpsellBubblePath` клэмпит `progress` в `0f..1f` (см. 9.4) — предыдущая попытка снять клэмп только для `bodyRect` (см. историю правок в этой же сессии, версия с `pBody`) была геометрически корректной (экстраполяция `lerp` за `progress>1` действительно давала прямоугольник больше финального), но результат не рендерился: DrawScope/`onDrawBehind` рисует в канвас, физически ограниченный размером `size` самого layout-узла — координаты Path за его пределами не "вылезают" наружу, а просто не попадают на растр (в отличие от `graphicsLayer`-трансформаций, которые применяются при композитинге слоя в родителя и потому НЕ ограничены исходным размером узла — подтверждено отдельным изолированным тестом: `graphicsLayer{scaleX=2f}` на 50dp `Box` рендерится как честные ~100dp, не обрезаясь). + - Исправлено: экстраполяция geometry откачена (снова чистый клэмп, как было), вместо неё — `graphicsLayer{scaleX=scaleY=bounceScale}` на всём `Box` тела (`UpsellBubbleHost.kt`, `UpsellBubbleBody`), где `bounceScale = entry.expandProgress().coerceAtLeast(1f)` (RAW/не клэмплен; `coerceAtLeast(1f)` сам по себе обнуляет эффект при схлопывании, где raw progress ⩽1 — овершут по размеру остаётся только при раскрытии, как и просили). Пивот трансформации (`transformOrigin`) — не дефолтный центр слоя, а центр круга-якоря (вычислен через `circleSize`/`anchoredToStart`), чтобы сам якорь не сдвигался, а "раздувался" именно дальний от него край — визуально тот же эффект, которого добивалась (но не смогла дать) правка геометрии. + - Подтверждено и численно (лог: `bounceScale` пик `1.0999` на 120мс из 300 — почти ровно 10%), и визуально (кроп+4× Lanczos на пике 120мс против осевшего 150мс — тело заметно крупнее, отправлено пользователю). +- [x] 13.3 Все временные debug-`println`/тестовый класс (`UpsellBubbleOvershootCheckTest.kt`, изолированный `ClipBoundsCheckTest.kt` для проверки гипотезы про clip) и их скриншоты удалены после локализации. +- [x] 13.4 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, посторонние скриншоты не тронуты. Обычные golden-скриншоты не изменились (оба фикса влияют только на промежуточные кадры анимации, не на состояния покоя `progress∈{0,1}`, где `bounceScale=1`/`shapeProgress` уже осел). + +## 14. Овершут поворота не был виден при схлопывании — баг в моменте handoff overlay→локальный бейдж + +Пользователь после 13: «овершут при разворачивании я увидел. но при сворачивании кнопка в конце не делает овершут». Раздел 13.1 исправил САМУ математику овершута (`iconShapeProgress`), но овершут при схлопывании всё равно не рендерился на экране — причина в другом месте: в условии, которое решает, кто именно рисует бабл в данный момент — overlay (с актуальным анимированным `shapeProgress`) или локальный статичный бейдж на месте триггера (с захардкоженными `alphaProgress={0f}, shapeProgress={0f}`, БЕЗ анимации). + +- [x] 14.1 **Причина**: регистрация overlay и alpha локального бейджа были завязаны на ЗНАК `expandProgress.value` (`> 0f` / `<= 0f`). С овершутом `expandProgress.value` при схлопывании уходит В ОТРИЦАТЕЛЬНУЮ область РАНЬШЕ, чем анимация реально завершилась (не только в самом конце) — это преждевременно снимало overlay с регистрации и включало статичный локальный бейдж на остаток анимации, обрезая тем самым весь провал/восстановление овершута, который overlay ещё должен был дорисовать. + - Первая попытка (`!= 0f` / `== 0f`, точное сравнение с нулём) была логически верна для этого частного случая, но дала новый баг — заметный флик видимости (`false→true→false`), похоже из-за неатомарного чтения нескольких независимых Snapshot-значений (`state`, `expandProgress.value`, `dismissAlpha.value`) внутри одного `snapshotFlow{}`. + - Исправлено переходом на явный булев флаг `isTransitioning` (`UpsellBubbleTrigger.kt`) — выставляется вручную `true`/`false`, оборачивая КАЖДУЮ анимационную корутину (expand/collapse/dismiss), а не выводится из значения анимированной величины. Условие регистрации overlay: `state == Expanded || isTransitioning || dismissAlpha.value < 1f`; альфа локального бейджа: `if (state == Collapsed && !isTransitioning) 1f else 0f`. Учтено, что `LaunchedEffect(state)` при внешнем `state = Hidden` отменяет текущую корутину — код ПОСЛЕ точки приостановки в отменённой корутине не выполнится, поэтому `isTransitioning = false` продублирован явно в ветке внешнего скрытия (`LaunchedEffect(hasNewOffer)`), а не только в конце корутины `LaunchedEffect(state)`. +- [x] 14.2 **Верификация**: значения (`state`, `isTransitioning`, `rotationZ`) напрямую прослежены логированием — `rotationZ` при схлопывании реально проскакивает цель (`+4.42°` мимо `0°`, та же цифра, что и в 13.1), `isTransitioning` корректно держит overlay зарегистрированным на всю длительность collapse-анимации без флика. Получить чистый автоматический скриншот-пруф именно для этого случая (аналогично 13.2) не удалось — покадровая съёмка через Robolectric `mainClock.advanceTimeBy` для сценария с 5-секундным auto-collapse `delay()` давала замороженные/идентичные кадры при нескольких разных стратегиях сэмплирования, несмотря на то что прямое логирование значений в тех же прогонах подтверждало их корректное изменение. Расценено как ограничение тестового харнесса (Robolectric test-clock + `delay()`-таймер + мелкая покадровая выборка), а не как баг прод-кода — визуальное подтверждение для этого конкретного случая остаётся за пользователем (реальное устройство/эмулятор). +- [x] 14.3 Все временные тестовые файлы этого раунда расследования (`UpsellBubbleHandoffVerifyTest.kt`, `UpsellBubbleQuickCheckTest.kt`, `UpsellBubbleFinalCheckTest.kt`) и их скриншоты/build-артефакты удалены; `grep -rln "class UpsellBubble" src/test` — пусто (кроме легитимного `ComposeUpsellBubbleScreenshotTest.kt`), `println(...)` в проде — пусто. +- [x] 14.4 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, golden-скриншоты не изменились, `git status` по `screenshots-compose` чист от посторонних файлов. + +## 15. Овершут полностью откачен — на практике смотрелся плохо + +Пользователь после 13-14: «откатывай всё, что связано с овершутом. получилось очень плохо, особенно конец сворачивания, все дёргается и мигает». Живьём (не на синтетических скриншотах/логах) весь эффект — и по размеру тела, и по углу поворота — читался как дребезг/дёрганье, а не как приятный "перелёт-и-возврат", особенно заметно в хвосте сворачивания. Раздел 13-14 численно доказывал, что механика работает как задумано (значения реально проскакивают цель на ~10%), но визуальное качество самого эффекта на реальном устройстве оказалось неприемлемым — это не баг реализации, а решение отказаться от самого эффекта. + +- [x] 15.1 Полностью удалён `UpsellBubbleOvershootEasing.kt`. +- [x] 15.2 `UpsellBubbleTrigger.kt` — убраны `isTransitioning`, `iconShapeProgress`; `expandProgress`/`iconMorph` вернулись к состоянию до раздела 9.4/13: `easing = LinearOutSlowInEasing` (не Overshoot) для `expandProgress`, дефолтный easing для `iconMorph`. Условие регистрации оверлея вернулось к `state == Expanded || expandProgress.value > 0f || dismissAlpha.value < 1f` (см. 6.4) — без `isTransitioning`, поскольку без овершута `expandProgress.value` больше не уходит в отрицательную зону и не пересекает ноль многократно, так что вывод "анимация идёт" из знака `expandProgress.value` снова безопасен. Альфа локального бейджа вернулась к жёсткому переключению по `state == Collapsed` (см. 6.4, "регрессия — обводка расширяется над крестиком"). +- [x] 15.3 `UpsellBubbleTriggerIcon.kt` — сигнатура вернулась к одному `morphProgress: () -> Float` (вместо раздельных `alphaProgress`/`shapeProgress`); поворот рамки и масштаб/альфа иконок читают один и тот же линейный `morphProgress`, без овершут-кривой. +- [x] 15.4 `UpsellBubbleHost.kt` — убран `graphicsLayer{scaleX=scaleY=bounceScale}` блок (bounce-эффект размера тела при раскрытии), убрано поле `iconShapeProgress` у `UpsellBubbleOverlayEntry`, вызов `UpsellBubbleTriggerIcon` вернулся к `morphProgress = entry.iconMorphProgress`. Неиспользуемый импорт `TransformOrigin` удалён. +- [x] 15.5 `UpsellBubbleShape.kt` — убран комментарий, ссылавшийся на `UpsellBubbleOvershootEasing` (сам клэмп `progress.coerceIn(0f, 1f)` остался без изменений — он не специфичен для овершута). +- [x] 15.6 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, golden-скриншоты не изменились, `git status` чист (посторонних файлов нет, `UpsellBubbleOvershootEasing.kt` полностью убран из индекса как add+delete). + +## 16. Поворот обводки: направление и синхронизация с телом, единая шкала для всех анимаций + +Пользователь после отката овершута: «ты вращаешь по часовой стрелке при сворачивании, а надо против часовой. и ещё вращение заканчивается слишком рано, видно как круг схлопывается над крестообразной кнопкой». Разбор показал, что оба эффекта — не регрессия отката, а свойство исходного (до-овершутного, «идеально работавшего») дизайна из раздела 9.3: угол обводки всегда ВЫВОДИЛСЯ из `morphProgress` (`rotationZ = -45f * morphProgress()`), поэтому на сворачивании (`morphProgress: 1→0`) угол неизбежно ретрассировался назад по часовой, а не продолжал крутиться в ту же сторону; и `morphProgress` всегда анимировался на своей, более короткой `ICON_MORPH_DURATION_MS=175мс`, тогда как силуэт тела (`expandProgress`) — на `EXPAND_DURATION_MS=300мс`/`COLLAPSE_DURATION_MS=200мс`, поэтому поворот обводки (и вся иконка вообще) всегда заканчивался раньше геометрии тела. + +- [x] 16.1 **Направление** — заведён отдельный `Animatable` `iconRotation` (градусы, не привязан к `morphProgress`/`expandProgress` 0f..1f). На КАЖДОМ переходе (и разворот, и сворачивание) к текущему значению прибавляется один и тот же шаг `ROTATION_STEP_DEGREES = -45f` (отрицательный = против часовой, `UpsellBubbleTrigger.kt`) — обводка больше не разворачивается назад по своему же пути, а непрерывно крутится в одну сторону. Обводка (`UpsellBubbleStarFourShape`) имеет точную 4-кратную (90°) вращательную симметрию (проверено аналитически на координатах пути — поворот вершины на 90° точно совпадает со следующей вершиной пути) — после каждого полного сворачивания угол нормализуется `iconRotation.value % 90f` без видимого скачка, чтобы не расти неограниченно. Отдельно учтён краевой случай: ветка `Collapsed` в `LaunchedEffect(state)` срабатывает и при настоящем сворачивании, и при первом маунте (`Hidden → Collapsed`) — добавлена проверка `wasExpanded = expandProgress.value > 0f`, чтобы на маунте лишний шаг поворота не прибавлялся вхолостую (иначе он не виден сразу, но портит расчёт угла для следующего реального разворота). +- [x] 16.2 **Синхронизация с телом** — `iconRotation` анимируется на той же длительности, что и `expandProgress` (`EXPAND_DURATION_MS`/`COLLAPSE_DURATION_MS`), а не на укороченной `ICON_MORPH_DURATION_MS` — обводка теперь докручивается ровно до того момента, когда доедет силуэт тела, а не останавливается раньше. +- [x] 16.3 **По следующей правке пользователя** («анимация дёрганая, не плавная») — синхронизация обводки с телом (16.2) обнажила рассинхрон, который был и раньше, но был менее заметен: `iconMorph` (альфа+скейл звезды/крестика) оставался на отдельной `ICON_MORPH_DURATION_MS=175мс` с ДЕФОЛТНЫМ easing (`FastOutSlowInEasing`, S-образная кривая), тогда как тело и (после 16.2) обводка — на 300/200мс с `LinearOutSlowInEasing` (линейный старт). Иконка «доигрывала» и замирала раньше остальных элементов, да ещё и с другой формой кривой — читалось как рывок. Исправлено: `iconMorph.animateTo` переведён на те же `EXPAND_DURATION_MS`/`COLLAPSE_DURATION_MS` и `easing = LinearOutSlowInEasing`, что и `expandProgress`/`iconRotation` — все три анимации теперь на одной шкале. Константа `ICON_MORPH_DURATION_MS` удалена как более неиспользуемая. +- [x] 16.4 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, golden-скриншоты (состояния покоя `progress∈{0,1}`) не изменились — правки затрагивают только промежуточные кадры анимации. Автоматический покадровый скриншот-пруф для этого раунда не делался (по прямой просьбе пользователя не тратить время на хрупкие Robolectric-тесты для таких кейсов, см. историю раздела 14) — визуальная проверка на устройстве за пользователем. + +## 17. Направление поворота — не непрерывное, а "туда-обратно": 16.1 решал не ту задачу + +Следующая правка пользователя («по вращению креста: 1. когда бабл разворачивается — он должен крутиться по часовой стрелке, когда сворачивается — обратно против часовой») показала, что 16.1 неверно интерпретировал самую первую жалобу («вращаешь по часовой при сворачивании, а надо против часовой»): решение «крутить непрерывно в одну сторону на обоих переходах» (16.1) было избыточным — на деле нужно было простое «туда-обратно» с ДРУГИМ знаком, чем было исходно (было: разворот против часовой / сворачивание по часовой; нужно: разворот по часовой / сворачивание против часовой — т.е. просто зеркально исходному, не непрерывное вращение). + +- [x] 17.1 Весь механизм 16.1 (отдельный `Animatable iconRotation`, `ROTATION_STEP_DEGREES`, `ROTATION_SYMMETRY_DEGREES`, нормализация по модулю 90°, проверка `wasExpanded`, поле `iconRotation` у `UpsellBubbleOverlayEntry`, параметр `rotationDegrees` у `UpsellBubbleTriggerIcon`) полностью удалён — избыточная сложность для задачи, которая не требовала непрерывного накопления угла. +- [x] 17.2 `UpsellBubbleTriggerIcon.kt` — угол обводки снова прямая функция `morphProgress`: `rotationZ = BORDER_ROTATION_DEGREES * morphProgress()`, но знак константы ПЕРЕВЁРНУТ (`BORDER_ROTATION_DEGREES = +45f`, было `-45f`) — на развороте (`morphProgress: 0→1`) угол растёт `0°→+45°` (по часовой, положительный `rotationZ` в Compose визуально вращает по часовой), на сворачивании (`1→0`) убывает обратно `+45°→0°` (против часовой). Синхронизация по длительности/кривой с телом (16.2/16.3) сохранена бесплатно — она теперь просто следствие того, что `morphProgress` (переименованный `iconMorph`) уже анимируется на той же шкале, что и `expandProgress`, отдельный `Animatable` для угла для этого не нужен. +- [x] 17.3 `UpsellBubbleHost.kt`/`UpsellBubbleTrigger.kt` — убраны все следы `iconRotation` (поле в `UpsellBubbleOverlayEntry`, параметр `rotationDegrees` в обоих местах вызова `UpsellBubbleTriggerIcon`, `snapTo(0f)` в трёх местах сброса). +- [x] 17.4 **Проверка**: `compileDebugKotlin`/`detekt`/`spotlessKotlinCheck`/`testDebugUnitTest --tests "*UpsellBubble*"` — чисто, golden-скриншоты не изменились. + +Пункт 2 этой же правки пользователя («у креста альфа зелёного бэкграунда должна уменьшаться при разворачивании / добавляться при сворачивании») пока НЕ реализован — нужно уточнение, какой именно элемент имеется в виду (см. вопрос пользователю в чате), прежде чем резать геометрию `buildUpsellBubblePath`/`UpsellBubbleBody`. + +## 18. Альфа круглой подложки-якоря под бейджем-крестиком + +Уточнение от пользователя по п.2 раздела 17: альфу должна получить ТОЛЬКО круглая заливка-подложка под крестиком (не обводка, не сам крестик, не остальное тело) — значение идёт `1→0` по мере разворачивания (подложка исчезает) и обратно `0→1` при сворачивании. Эффект раньше не существовал вовсе (проверено — заливка всего Union-пути шла одним `drawPath` без какой-либо анимируемой альфы на круге отдельно). + +- [x] 18.1 `UpsellBubbleShape.kt` — координаты круга-якоря вынесены из `buildUpsellBubblePath` в отдельную переиспользуемую функцию `upsellBubbleCircleRect(size, layoutDirection, circleDiameterPx, direction)`, чтобы вызывающая сторона могла получить те же координаты круга напрямую, не дублируя формулу. `buildUpsellBubblePath` теперь вызывает её внутри себя же (поведение и итоговый Union-путь не изменились). +- [x] 18.2 `UpsellBubbleHost.kt` (`UpsellBubbleBody`, `drawWithCache`/`onDrawBehind`) — заливка тела разделена на ДВА слоя вместо одного `drawPath(path, bodyBrush)`: `restPath = path − circlePath` (`PathOperation.Difference`, тело+шейка БЕЗ круга) рисуется первым, полностью непрозрачным; `circlePath` (через `upsellBubbleCircleRect`) рисуется вторым, поверх, с `alpha = 1f - entry.iconMorphProgress()` — та же величина, что уже управляет альфой самого крестика/звезды в `UpsellBubbleTriggerIcon`, так что подложка тает синхронно с появлением крестика. Круг рисуется ПОСЛЕДНИМ (поверх restPath), чтобы граница выреза не давала шва при частичной альфе. Внутренний glow (`UpsellBubbleInnerGlowPainter`) продолжает рисоваться по ПОЛНОМУ (неразрезанному) `path`, как и раньше — альфой круга не затронут (не входило в объём правки, эффект неглавный/субтильный). +- [x] 18.3 **Проверка**: `compileDebugKotlin` — чисто (`detekt`/`spotlessKotlinCheck`/тесты по прямой просьбе пользователя в этом раунде не запускались — «сам запущу в конце, для экономии времени»; отложено на финальную проверку). + +**Весь раздел 18 ОТКАЧЕН в разделе 19** — трактовка «зелёный бэкграунд» = круг-якорь тела оказалась неверной, см. ниже. + +## 19. Правильная трактовка: не круг тела, а подложка ВНУТРИ обводки бейджа-крестика + +Пользователь, увеличив длительность анимаций до 1000мс для наглядности (`EXPAND_DURATION_MS`/`COLLAPSE_DURATION_MS` в `UpsellBubbleTrigger.kt` — оставлено как есть, это его собственная временная правка для визуальной проверки, не трогать без запроса), уточнил дважды: +1. «Обводка и есть крестообразная кнопка — должен крутиться и бэкграунд ВНУТРИ этой обводки» — вращение обводки (17) верно, но нужен ЕЩЁ один слой: заливка-подложка внутри контура обводки, которая крутится вместе с ней. +2. «Круг — часть развёрнутого бабла, его не трогаем; трогать надо крестообразную кнопку-триггер» — раздел 18 (альфа на круге-якоре тела) был НЕВЕРНОЙ трактовкой и полностью откатан. + +- [x] 19.1 `UpsellBubbleShape.kt` — раздел 18 полностью откатан: `upsellBubbleCircleRect` убрана, `buildUpsellBubblePath` вернулась к инлайновому расчёту `circleRect` (файл идентичен состоянию до раздела 18). +- [x] 19.2 `UpsellBubbleHost.kt` (`UpsellBubbleBody`) — заливка тела вернулась к одному `drawPath(path, bodyBrush)` (без Difference/раздельных слоёв на круге); неиспользуемые импорты `Path`/`PathOperation` убраны. +- [x] 19.3 `UpsellBubbleTriggerIcon.kt` — добавлен новый параметр `backgroundBrush: Brush` и НОВЫЙ слой — `Box` тем же размером `borderSize`, заливка через `UpsellBubbleStarFourShape.createOutline` (та же форма, что и у контура обводки), в `graphicsLayer` — `rotationZ = BORDER_ROTATION_DEGREES * morphProgress()` (то же вращение, что у обводки) И `alpha = 1f - morphProgress()` (гаснет при разворачивании, 1→0; появляется при сворачивании, 0→1). Контур обводки (`.border(...)`) остаётся ОТДЕЛЬНЫМ `Box` со своим `graphicsLayer` — только вращение, БЕЗ альфы (контур не гаснет, тает именно заливка внутри). Оба вызывающих места (`UpsellBubbleTrigger.kt` локальный бейдж, `UpsellBubbleHost.kt` угловой бейдж) передают `backgroundBrush = bodyBrush`. +- [x] 19.4 **Найденный (не гипотетический) баг — «пропала внутренняя тень на бэйдже» (репорт пользователя сразу после 19.3).** Причина: новый слой 19.3 рисует НЕПРОЗРАЧНУЮ заливку `bodyBrush` БЕЗ собственного glow, поверх уже существующей заливки+glow родителя (`UpsellBubbleTrigger.kt` для локального бейджа/`UpsellBubbleHost.kt` для углового) — Compose рисует детей Box ПОСЛЕ (поверх) собственных draw-модификаторов родителя, поэтому непрозрачный слой без glow просто перекрывал glow снизу, ничем его не заменяя. Особенно заметно у ЛОКАЛЬНОГО бейджа: там `morphProgress` всегда константа `{ 0f }`, поэтому alpha нового слоя всегда `1f` (полностью непрозрачно) — glow был перекрыт ПОСТОЯННО, а не только в кадрах анимации. У углового бейджа тот же эффект нарастал ближе к концу сворачивания (alpha снова стремится к 1). + - Исправлено: новый слой (`UpsellBubbleTriggerIcon.kt`) теперь рисует СОБСТВЕННЫЙ glow тем же приёмом (`createUpsellBubbleInnerGlowPainter`/`UPSELL_BUBBLE_INNER_GLOW_COLOR`/`UPSELL_BUBBLE_INNER_GLOW_BLUR_DP`), что и родительские заливки — теперь он самодостаточен визуально (заливка+glow вместе) и не перекрывает нижний слой без замены. Побочный эффект при частичной альфе (переходные кадры анимации): на короткое время может быть виден слегка усиленный/задвоенный glow (свой + родительский), т.к. они рисуются независимо друг поверх друга — сочтено приемлемым (эффект субтильный, blur, не резкая граница), не гонялось отдельно. +- [x] 19.5 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты по прямой просьбе пользователя не запускались в этом раунде — «я сам их запущу в конце». + +## 20. Две новые проблемы: скачок ближнего угла при сращивании с шейкой; не видно анимации бейджа-триггера + +Пользователь (увеличив длительность до 1000мс для наглядности) сообщил: (1) «в месте сращивания бабла с кругом, по мере раскрытия в конце резко выстреливает угол бабла, ближайший к шейке — то же и при сворачивании»; (2) «круг слишком быстро появляется над триггером закрывая его анимацию при открытии, и слишком долго уменьшается при закрытии» (после уточнения — «круг» здесь = круг-якорь тела, «триггер» = угловая кнопка-крестик). + +- [x] 20.1 **Проблема 1 — найдена и подтверждена математически, не гипотеза.** `UpsellBubbleShape.kt`: `nearCornerRoundTarget` (радиус скругления ближнего к шейке угла) раньше выбирался ЖЁСТКИМ веткой `if (neckIncluded) minOf(maxSafeCorner, marginToFillet*0.8f) else maxSafeCorner` — мгновенный скачок ЗНАЧЕНИЯ ровно в момент, когда шейка геометрически формируется (`neckIncluded` меняется с false на true) — `cornerBlend` в этой точке ещё 0 (порог `CORNER_SHARPEN_THRESHOLD=0.85` наступает намного позже), поэтому скачок ничем не сглаживался и был виден напрямую. + - Исправлено: переход сделан плавным линейным рампом по `neckGapPx = neckBottomY - neckTopY` (сама геометрическая величина, определяющая появление шейки) в окне `±neckFilletRadiusPx` вокруг точки пересечения — тот же масштаб, что уже используется для галтели шейки, органично согласован с остальной геометрией, а не отдельная подобранная на глаз константа. +- [~] 20.2 **Проблема 2 — расследование через видео, не гипотеза.** Пользователь прислал скринкаст; для разбора в систему поставлен `ffmpeg` (`brew install ffmpeg`, локально для этой сессии), покадрово вырезаны и увеличены кадры именно угловой кнопки. Обнаружено: угловая кнопка (бейдж) действительно на всех кадрах перехода выглядит смазанным нечётким пятном без явно читаемого вращения — но НЕ из-за z-order (проверено по коду: бейдж — ребёнок `Box`, тело рисуется в `onDrawBehind` того же `Box`, т.е. родитель рисует СВОЙ фон, а дети — включая бейдж — поверх; бейдж физически не может быть закрыт телом). Настоящая причина эффекта «не видно затухания фона бейджа» — `backgroundBrush` бейджа (раздел 19.3) и `bodyBrush` круга-якоря ПОД ним — БУКВАЛЬНО ОДНА И ТА ЖЕ кисть (`bodyBrush`, передаётся одинаково в обоих местах вызова `UpsellBubbleTriggerIcon`), поэтому когда фон бейджа гаснет (альфа 1→0), под ним открывается АБСОЛЮТНО ТОТ ЖЕ ЦВЕТ — визуально ничего не меняется. Обводка (единственное, что реально движется) — тонкая линия 1dp на маленьком бейдже, на видео при сжатии/масштабе теряется в смаз. + - Пользователю предложены варианты (другой оттенок фона бейджа / более толстая контрастная обводка) — вместо этого выбран другой путь: **раскрытие тела должно идти ЗАМЕТНО ДОЛЬШЕ, чем анимация бейджа**, чтобы бейдж успевал полностью доиграть, пока тело ещё растёт, а не финишировать одновременно с ним. + - Исправлено: `iconMorph` (альфа фона+иконки, и вместе с ней — поворот обводки, читающий тот же `morphProgress`, см. 17.2) снова на ОТДЕЛЬНОЙ, короче тела, длительности — но, в отличие от исходной до-16.3 версии, на ТОЙ ЖЕ кривой `LinearOutSlowInEasing`, что и `expandProgress` (не дефолтной `FastOutSlowInEasing`) — рассинхрон 16.3 был именно из-за разной ФОРМЫ кривой, а не самого факта разных длительностей. Новые константы `ICON_MORPH_EXPAND_DURATION_MS`/`ICON_MORPH_COLLAPSE_DURATION_MS` считаются как ПОЛОВИНА от `EXPAND_DURATION_MS`/`COLLAPSE_DURATION_MS` СВОЕГО направления (не общая на оба направления константа) — так соотношение «бейдж быстрее тела» сохраняется, даже если пользователь поменяет длительности тела ещё раз. + - **Не устранено (открытый вопрос, не в объёме этой правки):** сама проблема «фон бейджа гаснет в тот же цвет, что и под ним» никуда не делась — эффект по-прежнему не будет виден МЕЖДУ бейджем и телом по цвету, только вращение обводки. Пользователь в курсе, выбрал таймингово-паузный путь вместо смены цвета/толщины обводки. +- [x] 20.3 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. Временные файлы анализа видео (`ffmpeg`-кадры) удалены из scratchpad. + +## 21. Тайминговый фикс 20.2 не помог — решение через размер бейджа, а не время; плюс альфа тела + +Пользователь: раздельные длительности (20.2) не устранили проблему «не видно вращения триггера». Предложил другой механизм: «круговая часть тела бабла в начале раскрытия и в конце сворачивания должна быть МЕНЬШЕ по размеру, чем крестообразная кнопка — тогда будет видно вращение триггера» — то есть контраст должен идти не по цвету (см. 20.2 — фон бейджа и круг тела буквально одна кисть), а по СИЛУЭТУ: если рамка+фон бейджа крупнее круга под ней, вращающийся край будет виден на фоне ЧЕГО-ТО ЕЩЁ (не того же самого круга), а не тонуть в нём. Плюс отдельная правка «кстати у тела при появлении и исчезании тоже должна меняться альфа аналогично триггеру». + +- [x] 21.1 **Размер бейджа (не геометрия тела)** — `UpsellBubbleTriggerIcon.kt`: добавлен параметр `sizeScale: () -> Float`, масштабирует ТОЛЬКО рамку+фон (оба через `graphicsLayer{scaleX/scaleY}`), НЕ сами иконки-глифы (у них свой независимый скейл через `STAR_SHRINK_FACTOR`/`CLOSE_START_SCALE`). Реализовано через МАСШТАБ поверх фиксированного `borderSize`, а не через переменный `Dp`-размер — тот же приём, что уже используется в компоненте для реактивных изменений без пересборки layout (recomposition) на каждый кадр. + - `UpsellBubbleHost.kt` (угловой бейдж): `borderSize` изменён на полный `circleSize` (было `circleSize * UPSELL_BUBBLE_BADGE_BORDER_SCALE`) — это теперь БАЗА для масштаба; `sizeScale = { lerp(BADGE_SIZE_OVERSHOOT, UPSELL_BUBBLE_BADGE_BORDER_SCALE, entry.iconMorphProgress()) }` — на `morphProgress=0` рамка `circleSize × 1.15` (крупнее круга-якоря тела, новая константа `BADGE_SIZE_OVERSHOOT=1.15f`, подобрана на глаз, не сверена с макетом — эффекта раньше не было), на `morphProgress=1` — ровно `circleSize × UPSELL_BUBBLE_BADGE_BORDER_SCALE` (штатный, выверенный по Figma размер бейджа на углу развёрнутого тела) — итоговый размер В ПОКОЕ не изменился, меняется только переходный пик. + - `UpsellBubbleTrigger.kt` (локальный/свёрнутый бейдж): `sizeScale = { 1f }` — не затронут, там рамка и так уже равна полному кругу. + - ВАЖНО: круг-якорь тела (сама геометрия `buildUpsellBubblePath`/`circleDiameterPx`) НЕ ТРОНУТ — размер меняется только у САМОГО БЕЙДЖА (независимый слой поверх), а не у общей формы, которую просили не трогать раньше. +- [x] 21.2 **Альфа тела** — `UpsellBubbleHost.kt` (`UpsellBubbleBody`, `onDrawBehind`): `drawPath(path, bodyBrush)` → `drawPath(path, bodyBrush, alpha = entry.expandProgress().coerceIn(0f, 1f))` — та же величина, что уже управляет альфой content-слота (текста) чуть ниже — тело (включая круг) теперь материализуется/растворяется вместе с текстом, а не всегда полностью непрозрачно. Glow НЕ затронут (тот же осознанный компромисс, что и в 19.4/20 — рисуется как раньше, без альфы). +- [x] 21.3 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. + +## 22. 21.1 дал видимый скачок размера ровно на границе хэндоффа с локальным бейджем + +Пользователь прислал новый скринкаст: «в самом начале разворачивания и в самом конце сворачивания триггер скачком меняет свой размер (становится больше, чем оригинальный)». Разбор (покадрово через `ffmpeg`, как и раньше) подтвердил: между двумя соседними кадрами бейдж прыгает с обычного маленького (локальный, свёрнутый) на заметно бо́льший — визуальное подтверждение, что баг именно тот, что предсказывала логика кода. + +- [x] 22.1 **Причина**: 21.1 делал `sizeScale = lerp(BADGE_SIZE_OVERSHOOT, UPSELL_BUBBLE_BADGE_BORDER_SCALE, morphProgress)` — овершут (1.15×) стоял РОВНО в `morphProgress=0`, а это ТА ЖЕ ТОЧКА, где происходит хэндофф с локальным бейджем (`sizeScale` которого всегда `1f`, БЕЗ овершута) — на самой границе переключения локальный→угловой бейдж размер скачком менялся с `1f` на `1.15×`, и обратно при сворачивании. +- [x] 22.2 **Исправлено** (`UpsellBubbleHost.kt`) — вместо линейного лерпа между крайними значениями теперь "горб": `lerp(1f, UPSELL_BUBBLE_BADGE_BORDER_SCALE, t) + BADGE_SIZE_BUMP * sin(π·t)`, где `t = morphProgress()`. На границах (`t=0` и `t=1`) `sin(π·t)=0`, поэтому масштаб ТОЧНО совпадает с соседними состояниями (`1f` — размер локального бейджа при хэндоффе, `UPSELL_BUBBLE_BADGE_BORDER_SCALE` — штатный осевший размер) — скачков на границах больше нет. Овершут (пик ≈`0.91 + 0.25 = 1.16`, новая константа `BADGE_SIZE_BUMP=0.25f`) остаётся, но смещён в СЕРЕДИНУ перехода — там, где ни с чем не граничит. +- [x] 22.3 **Проверка**: `compileDebugKotlin` — чисто. Видео пользователя (t≈1.0-1.8с) покадрово сверено на предмет скачка ДО фикса (подтверждён — прыжок между соседними кадрами) — после фикса отдельного видео не делалось (по прежней договорённости не тратить время на автоматические скриншот-пруфы, визуальная проверка за пользователем). `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. + +## 23. Овершут по размеру отклонён целиком, не только скачок на границе + +Пользователь: «теперь скачка нет, но зачем-то триггер в процессе вращения становится больше своего оригинального размера — так не должно быть. При разворачивании он может только уменьшаться, но никак не расти. Аналогично и при сворачивании». Это отклоняет саму идею 21.1/22.2 (сделать бейдж крупнее круга-якоря тела ради контраста при вращении), а не только скачок на границе, который чинил раздел 22. + +- [x] 23.1 `UpsellBubbleHost.kt` — "горб" (`sin(π·t)`-надбавка, 22.2) убран целиком. `sizeScale` снова строго `lerp(1f, UPSELL_BUBBLE_BADGE_BORDER_SCALE, entry.iconMorphProgress())` — монотонно убывает при разворачивании (`1f → ~0.81`, никогда не превышая `1f`) и монотонно растёт обратно при сворачивании, тоже не превышая `1f`. Константа `BADGE_SIZE_BUMP` и импорты `kotlin.math.sin`/`kotlin.math.PI` убраны как более не нужные. +- [x] 23.2 **Итог по видимости вращения** (раздел 20-23 в целом): идея «сделать круг-якорь меньше бейджа» отклонена пользователем. Единственное, что сейчас реально помогает увидеть анимацию триггера — раздельные (короче тела) длительности `ICON_MORPH_EXPAND_DURATION_MS`/`ICON_MORPH_COLLAPSE_DURATION_MS` (раздел 20.2) и альфа тела (раздел 21.2, тело материализуется/тает вместе с текстом). Сам факт, что фон бейджа и круг-якоря — одна и та же кисть (найдено в разделе 20.2), остаётся неустранённым и, видимо, принят как есть. +- [x] 23.3 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. + +## 24. Альфа тела резко «щёлкает» в конце сворачивания, будучи ещё не нулевой + +Пользователь: «при сворачивании бросается в глаза в конце, что круг (остаток тела бабла) резко исчезает будучи ещё не полностью прозрачным. проблема в таймингах?» — да, подтверждено, конкретная причина найдена (не гипотеза). + +- [x] 24.1 **Причина**: альфа тела (21.2) была напрямую `expandProgress().coerceIn(0f,1f)` — та же величина, что двигает и геометрию, и `LinearOutSlowInEasing` У СВОЕЙ ЦЕЛИ ЗАМЕДЛЯЕТСЯ (что и нужно для геометрии — линейный старт, плавное торможение к концу). Побочный эффект для АЛЬФЫ: `expandProgress` при сворачивании подолгу «зависает» на малых-но-ненулевых значениях near `p→0` (мало меняется в реальном времени из-за замедления), прежде чем `tween` на последнем кадре принудительно доводит его РОВНО до 0 — альфа всё это время оставалась слабо, но заметно ненулевой, и лишь в последний кадр скачком становилась 0 (плюс оверлей в этот же кадр снимается с регистрации) — читается глазом как внезапное исчезновение недоистаявшего круга. +- [x] 24.2 **Исправлено** (`UpsellBubbleHost.kt`) — `bodyAlpha = (expandProgress() / BODY_ALPHA_EARLY_FADE_THRESHOLD).coerceIn(0f, 1f)`, новая константа `BODY_ALPHA_EARLY_FADE_THRESHOLD=0.7f` (на глаз, не сверено с макетом). Альфа теперь доходит до 0 (при сворачивании) или 1 (при разворачивании) РАНЬШЕ, чем `expandProgress` доходит до своей цели — то есть тело успевает полностью раствориться/проявиться, пока `expandProgress` ещё в "быстрой" части кривой, а не висит в её медленном хвосте у самой цели. Content-слот (текст) НЕ тронут — там та же старая формула `expandProgress().coerceIn(0f,1f)`, жалоб на него не было, чтобы не рисковать регрессией в том, что уже работает. +- [x] 24.3 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. + +**24.2 ОШИБОЧЕН, исправлено в разделе 25** — деление на порог < 1 меняет только ВЕРХНЮЮ границу диапазона (альфа раньше достигает 1), а нижняя граница (альфа→0 у p→0) остаётся ровно там же, где и была — то есть фикс был no-op именно для собщённого пользователем случая (хвост СВОРАЧИВАНИЯ, где альфа должна раньше доходить до 0, а не до 1). + +## 25. Исправление 24.2 не сработало — деление не трогало нужную границу + +Пользователь после 24: «я всё равно вижу как в конце на триггер наложено не до конца исчезнувшее тело, а потом он резко гаснет» — та же проблема, фикс не помог. При объяснении механики Path пользователю (см. предыдущий ответ в чате) сама формула `p / BODY_ALPHA_EARLY_FADE_THRESHOLD, затем .coerceIn(0f,1f)` была перепроверена и найдена ошибочной. + +- [x] 25.1 **Причина ошибки 24.2**: `.coerceIn(0f, 1f)` после деления обрезает только СВЕРХУ — при `p→0` (хвост сворачивания, ИМЕННО тот случай, о котором был репорт) `p / 0.7 → 0` точно так же, как и без деления, деление тут вообще ничего не меняет. Фикс реально помогал только хвосту РАЗВОРОТА (`p→1`, где альфа теперь раньше достигает 1) — противоположному концу, о котором жалоб не было. +- [x] 25.2 **Исправлено** (`UpsellBubbleHost.kt`) — вместо деления на один порог теперь линейный ремап между ДВУМЯ границами: `bodyAlpha = ((expandProgress() - BODY_ALPHA_LOW) / (BODY_ALPHA_HIGH - BODY_ALPHA_LOW)).coerceIn(0f, 1f)`, новые константы `BODY_ALPHA_LOW=0.3f`/`BODY_ALPHA_HIGH=0.7f` (симметрично вокруг середины, на глаз). Теперь альфа=0 уже при `p<=0.3` (не только в `p=0` — именно то, чего не хватало) и альфа=1 уже при `p>=0.7`. Старая константа `BODY_ALPHA_EARLY_FADE_THRESHOLD` заменена на эти две. +- [x] 25.3 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. + +## 26. 25 тоже не помог — реальная причина: glow рисовался БЕЗ альфы вообще + +Пользователь: «ты ничего не пофиксил» — 25.2 (правильный ремап альфы заливки) не устранил жалобу. Разбор (перечитан весь `onDrawBehind` заново) нашёл ВТОРОЙ, отдельный источник того же визуального симптома, который 21.2/24/25 не затрагивали вовсе: `glowPainter.draw(...)` — ОТДЕЛЬНЫЙ вызов, рисующий внутреннюю тень в свой собственный Bitmap/Canvas и блитующий его — вызывался БЕЗ какой-либо альфы, целиком независимо от `bodyAlpha`. Поэтому даже когда заливка (`drawPath(path, bodyBrush, alpha=bodyAlpha)`) уже полностью погасла, glow ПРОДОЛЖАЛ рисоваться на полную силу до самого конца — то самое «не до конца исчезнувшее тело, наложенное на триггер», которое пользователь продолжал видеть, и «резкое гашение» — момент, когда оверлей наконец снимается с регистрации целиком (единственное, что раньше останавливало вечно-непрозрачный glow). + +- [x] 26.1 `UpsellBubbleInnerGlowPainter.kt` — `draw(path, color, blurRadiusPx)` получил новый параметр `alpha: Float = 1f` (дефолт — старые вызовы из `UpsellBubbleTrigger.kt`/`UpsellBubbleTriggerIcon.kt` не тронуты). Применяется на финальном `drawImage(bitmap.asImageBitmap(), alpha = alpha)` — умножает уже готовый растр целиком при блите, а не альфу штриха до размытия (иначе `BlurMaskFilter` размывал бы уже ослабленный, а не полноценный штрих, слегка меняя форму размытия на низкой альфе). Плюс ранний выход `if (blurRadiusPx <= 0f || alpha <= 0f) return`. +- [x] 26.2 `UpsellBubbleHost.kt` — вызов `glowPainter.draw(...)` теперь передаёт `alpha = bodyAlpha` (та же величина, что и у заливки) — glow теперь гаснет/проявляется СИНХРОННО с заливкой, а не независимо от неё. +- [x] 26.3 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. Пользователь подтвердил: «вот теперь да, ушла проблема». + +## 27. Снова синхронизировать вращение бейджа с появлением тела + +Пользователь: «давай теперь синхронизируем вращение с появлением тела? сейчас оно быстрее происходит, а пусть теперь одновременно будет снова». Откат раздельных длительностей из раздела 20.2 — назад к единой шкале (как было в разделе 16.3, до того как раздел 20 их развёл). + +- [x] 27.1 `UpsellBubbleTrigger.kt` — `iconMorph.animateTo(...)` (обе ветки, Collapsed/Expanded) снова на `COLLAPSE_DURATION_MS`/`EXPAND_DURATION_MS` — той же длительности и кривой (`LinearOutSlowInEasing`), что и `expandProgress`. Константы `ICON_MORPH_EXPAND_DURATION_MS`/`ICON_MORPH_COLLAPSE_DURATION_MS` (были `.../2`) убраны как более не нужные. +- [x] 27.2 **Проверка**: `compileDebugKotlin` — чисто. `detekt`/`spotlessKotlinCheck`/тесты не запускались по просьбе пользователя. + +## 28. Скругление на стыке шейки с кругом-якорем (по актуальному Figma-макету) + +Пользователь: «в месте сочленения шейки бабла с кругом тоже нужны скругления, посмотри на последних макетах». Ссылки на конкретный макет не было в контексте (сессия была сжата ранее) — запрошена и получена от пользователя: `https://www.figma.com/design/RHYbGlMV0pTMM0UM5TFOKG/...?node-id=3218-3419`. Скриншот узла получен через Figma MCP (`get_screenshot`), для детального разбора выкроен и увеличен через `ffmpeg` (тот же приём, что и для видео пользователя ранее) — на макете виден ЯВНЫЙ вогнутый скругление на нижнем стыке (шейка↔круг), визуально того же радиуса, что и на уже реализованном верхнем стыке (шейка↔тело). Раньше нижний стык был острым углом (`lineTo`) — намеренное решение по СТАРОЙ трактовке макета (см. KDoc `buildUpsellBubblePath`/`buildNeckPath` до этой правки), актуальный макет её отменяет. + +- [x] 28.1 **Геометрия** (`UpsellBubbleShape.kt`, `buildNeckPath`) — функция переработана: вместо одной галтели (верх) + двух `lineTo` (низ, острые углы) теперь ДВЕ пары зеркальных галтелей — верхняя (без изменений) и нижняя (новая, зеркало по оси Y). Радиус переиспользует ТУ ЖЕ величину, что и раньше была только у верхней галтели (`neckFilletRadiusPx` через общий `filletRadius`, параметр `topFilletRadius` переименован) — отдельного стилевого токена для нижнего радиуса не заводили: на макете оба скругления визуально одного размера, сверить по отдельности не с чем. + - Каждая из двух новых нижних галтелей выведена АНАЛИТИЧЕСКИ (через точки касания и знак `sweepAngle`), а не подобрана перебором — эта же функция уже раз давала self-intersection-баги при небрежном подборе дуг («Баг №5», см. историю выше в этом файле), поэтому вывод сделан зеркалированием уже провалидированной верхней пары галтелей относительно горизонтальной оси, с проверкой каждой точки касания и знака sweep отдельно (все четыре дуги в итоге получили ОДИНАКОВЫЙ знак sweep — само по себе хороший признак самосогласованности вывода). +- [x] 28.2 **Проверка — визуальная, не пиксель-в-пиксель.** `compileDebugKotlin` — чисто. `./gradlew verifyRoborazziDebug` в этом локальном окружении оказался НЕ пригоден для сравнения: упал не только на затронутых этой правкой тестах, но и на `testHiddenShowsNothing`/`testCollapsedShowsStarIcon` (ветки кода, вообще не задействующие `buildNeckPath`) — Reference-панель в HTML-diff была ПУСТОЙ (эталонный PNG не прочитался в этом окружении), а не просто отличалась — экологическая проблема окружения (шрифты/рендер), а не следствие этой правки, тот же класс несовпадений Robolectric/реальное устройство, что уже фиксировался в истории этого компонента. Вместо доверия pixel-diff — вырезан и увеличен (`ffmpeg`) фактический рендер (`build/outputs/roborazzi/..._actual.png`) для обоих тем (light/dark): стык шейка↔круг теперь гладкая симметричная кривая без самопересечений и изломов, визуально соответствует макету. + - `recordRoborazziDebug` был запущен, но ОТКАЧЕН: помимо ожидаемо изменившихся expanded-скриншотов, поменялся байт-в-байт и `testCollapsedShowsStarIcon` — код которого НЕ обращается к `buildNeckPath` вообще — подтверждает, что рендер в этом окружении систематически отличается от того, что зафиксировано в существующих golden-файлах, по причинам, не связанным с этой (или вообще какой-либо) правкой кода. Перезаписывать golden-файлы окружением, которое проверенно "шумит" даже на несвязанном коде, признано небезопасным — `git restore --staged --worktree` вернул все шесть файлов (`testCollapsedShowsStarIcon`/`testExpandedShowsBubbleBody`/`testExpandedBubbleOverlapsNarrowNeighborCards`, обе темы) к состоянию до записи; `testHiddenShowsNothing` не менялся вовсе (пустой кадр, экологический шум там не проявляется). `git status` по `screenshots-compose` — чист. + +## 29. Овершут возвращён — ВТОРАЯ попытка, архитектурно иначе + +Пользователь: «нужно чтобы анимация была как пружинка — выстреливает чуть больше чем надо, затем возвращается к целевому значению» — для вращения обводки и растягивания тела. Первая попытка (раздел 9.4/13-15) была ПОЛНОСТЬЮ откачена — овершут-easing стоял НАПРЯМУЮ на `expandProgress`, от которого также зависят регистрация оверлея (`expandProgress.value > 0f`), геометрия и альфа — уход значения в отрицательную область при сворачивании ломал регистрацию (снятие оверлея ДО того, как овершут доигрывал) и читался как дёрганье/мигание. + +- [x] 29.1 `UpsellBubbleOvershootEasing.kt` — воссоздан (тот же `easeOutBack`, тензия 1.70158, ~10% перелёта — Robert Penner/`OvershootInterpolator`). +- [x] 29.2 **Архитектура на этот раз — овершут НИКОГДА не касается `expandProgress`/`iconMorph`.** `UpsellBubbleTrigger.kt`: два НОВЫХ отдельных `Animatable` — `rotationProgress` (поворот обводки) и `bounceProgress` (пружинный масштаб тела) — оба анимируются с `UpsellBubbleOvershootEasing`, на той же длительности, что и `expandProgress`/`iconMorph` (`EXPAND_DURATION_MS`/`COLLAPSE_DURATION_MS`, синхронизация из раздела 27 не тронута). `expandProgress`/`iconMorph` остаются как были — монотонные, `LinearOutSlowInEasing`, ничем не задеты. Условие регистрации оверлея (`snapshotFlow` в `UpsellBubbleTrigger.kt`) читает ТОЛЬКО `expandProgress`/`state`/`dismissAlpha` — `rotationProgress`/`bounceProgress` туда намеренно не попадают, структурно исключая прошлый класс бага, а не просто смягчая его. + - Сброс (`snapTo(0f)`) для обоих новых `Animatable` добавлен во все три существующих места сброса (внешнее скрытие, явный дисмисс по крестику) — по аналогии с `expandProgress`/`iconMorph`. +- [x] 29.3 **Вращение** (`UpsellBubbleTriggerIcon.kt`) — `rotationZ` обводки (оба слоя — фон и контур) теперь читает НОВЫЙ параметр `rotationProgress` (`BORDER_ROTATION_DEGREES * rotationProgress()`), а не `morphProgress`. `morphProgress` остаётся у альфы/масштаба иконок и альфы фона-подложки — овершут туда не идёт (для alpha значения за пределами 0f..1f физического смысла не имеют). Оба вызывающих места (`UpsellBubbleTrigger.kt` локальный бейдж передаёт `{ 0f }`, `UpsellBubbleHost.kt` угловой — `entry.rotationProgress`) обновлены. +- [x] 29.4 **Растягивание тела** (`UpsellBubbleHost.kt`) — возвращён `graphicsLayer{scaleX=scaleY=bounceScale}` на `Box` тела (тот же приём, что и в разделе 13.2 истории: `entry.bounceProgress().coerceAtLeast(1f)`, пивот в центре круга-якоря через `TransformOrigin`) — эффект есть ТОЛЬКО при разворачивании (raw-значение >1 только у пика овершута там; при сворачивании raw-значение всегда ⩽1, `coerceAtLeast(1f)` даёt 1f, эффекта нет — симметричный "провал ниже цели" при сворачивании физически не имеет смысла, коллапс схлопывается В точку). `buildUpsellBubblePath` по-прежнему получает НЕтронутый `expandProgress` — геометрия остаётся клэмплена и монотонна, овершут по размеру идёт ИСКЛЮЧИТЕЛЬНО через масштаб слоя. +- [x] 29.5 **Проверка**: `compileDebugKotlin` — чисто. По прямой просьбе пользователя тесты/detekt/spotless НЕ запускались в этом раунде — визуальную проверку откладываем на пользователя. + +## 30. Заброс вращения посильнее — 10% → 15% + +Пользователь подтвердил, что раздел 29 работает: «хорошо, работает. давай только сделаем заброс посильнее для вращения. может не 10 процентов, а 15» — ТОЛЬКО для вращения, не для пружинного масштаба тела. + +- [x] 30.1 `UpsellBubbleOvershootEasing.kt` — класс параметризован по `tension` (был синглтон-`object` с зашитой константой) — тензия сама по себе не в процентах, это параметр формулы `easeOutBack`, управляющий силой перелёта. Заведены два именованных инстанса: `UpsellBubbleBounceEasing` (тензия 1.70158, ~10%, для масштаба тела — не тронут) и `UpsellBubbleRotationOvershootEasing` (тензия 2.163, ~15%, для вращения обводки). + - Тензия для 15% подобрана АНАЛИТИЧЕСКИ, не на глаз: пик `easeOutBack` равен `4·k³ / (27·(k+1)²)` (выведено из условия `f'(t)=0`, проверено на классическом k=1.70158 → даёт ровно 10.00%) — численно решено `4k³/(27(k+1)²)=0.15` → k≈2.163. +- [x] 30.2 `UpsellBubbleTrigger.kt` — `rotationProgress.animateTo(...)` (обе ветки) переведён на `UpsellBubbleRotationOvershootEasing`; `bounceProgress.animateTo(...)` остался на `UpsellBubbleBounceEasing` (10%, не тронут). +- [x] 30.3 **Проверка**: `compileDebugKotlin` — чисто. Тесты/detekt/spotless не запускались по прямой просьбе пользователя. + +## 31. Placement/Alignment — полная переработка размещения тела относительно триггера + +Пользователь: «нам нужно предусмотреть все варианты размещения бабла относительно триггера... нужно предусмотреть 2 режима: автовыбор по доступному месту, и пользовательский, когда юзер сам даёт сторону». После нескольких раундов уточнения (первые две трактовки — компас из 8 направлений, и 3×3 `Alignment`-сетка — были отклонены) сошлись на стандартной popover/tooltip-терминологии: `placement` (Top/Bottom/Start/End — с какой стороны от триггера появляется тело) × `alignment` (Start/Center/End — выравнивание вдоль перпендикулярной оси) = 12 комбинаций. Автовыбор по доступному месту (границы `UpsellBubbleHost`) — ОТДЕЛЬНО ОГОВОРЕНО И ОТЛОЖЕНО на следующий раунд, в этом разделе не реализован. + +- [x] 31.1 **Новые публичные enum** (`UpsellBubbleState.kt`) — `UpsellBubblePlacement` (Top/Bottom/Start/End) и `UpsellBubbleAlignment` (Start/Center/End) заменили `UpsellBubbleExpandDirection` (Start/End), которая удалена целиком. +- [x] 31.2 **Геометрия НЕ переписана 12 раз** (`UpsellBubbleShape.kt`) — вся дуговая/lerp-математика (историчски источник self-intersection-багов при небрежных правках, «Баг №5») написана ОДИН раз в канонической ориентации (`buildCanonicalBubblePath` — по сути старая `buildUpsellBubblePath`, круг снизу, тело растёт вверх, `alignment` управляет горизонтальной позицией круга; ЕДИНСТВЕННОЕ отличие от старой версии — добавлен `UpsellBubbleAlignment.Center`: круг по центру, ОБА нижних угла получают обычный/дальний радиус, а не уменьшенный — при центрированной шейке нет однозначно "ближнего" угла). Остальные 3 `placement` получаются применением `Path.transform(Matrix)` к уже построенному каноническому `Path` — НЕ ручным выводом дуг под 4 ориентации (это осознанно избегалось — источник прошлых самопересечений). Компоненты матриц (`placementMatrix`) выведены аналитически и задаются напрямую через ячейки (`matrix[row,col]`), а не цепочку `.scale()/.translate()` — чтобы не зависеть от неочевидного порядка применения этих операций. +- [x] 31.3 **RTL** — `resolveUpsellBubblePlacement` (общая функция, теперь `internal`, переиспользуется и геометрией, и `UpsellBubbleHost.kt`) зеркалирует `placement` всегда (Start/End сами по себе заданы относительно текста) и `alignment` только когда ось выравнивания горизонтальна (`placement`=Top/Bottom). Для layout-логики (`Modifier.align`/`PaddingValues(start=,end=)`) RTL НЕ резолвится вручную — используются нативные Compose Start/End-примитивы напрямую, что математически эквивалентно ручному резолву (оба — "поменять Start↔End под RTL"), лишь бы не было двойного зеркалирования. +- [x] 31.4 **Позиционирование** (`UpsellBubbleHost.kt`) — `upsellBubbleOverlayPlacement` (позиция overlay-бокса целиком относительно `anchorPosition`), `upsellBubbleBadgeAlignment` (где сидит бейдж-крестик — 2D `Alignment`, собран вручную через `Alignment { size, space, ld -> IntOffset(...) }`, т.к. у `Alignment` нет фабрики `(Horizontal, Vertical) -> Alignment`), `upsellBubbleReservedPadding` (на какой стороне контента резервировать место под круг+шейку — раньше всегда `bottom`) и `upsellBubbleBouncePivot` (пивот "пружинного" scale-овершута тела, раздел 29/30) — все обобщены под 4×3 вместо жёсткого "круг снизу". `bodyMinHeight` (стилевой токен, не переименован) применяется как `heightIn` при Top/Bottom и `widthIn` при Start/End — ось роста тела теперь не всегда вертикальная. +- [x] 31.5 **Публичный API** (`UpsellBubbleTrigger.kt`) — параметр `direction: UpsellBubbleExpandDirection` заменён на `placement: UpsellBubblePlacement = Top` + `alignment: UpsellBubbleAlignment = End` (дефолты сохраняют СТАРОЕ поведение по умолчанию — было `direction=End`, что соответствовало "круг снизу всегда + End" = ровно `Top`+`End`). +- [x] 31.6 **Обновлены все потребители старого API** — `UpsellBubbleStory.kt` (сэндбокс), `docs/override-docs/.../UpsellBubbleUsage.md`, `ComposeUpsellBubbleScreenshotTest.kt` (golden-тест). +- [x] 31.7 **Проверка**: `compileDebugKotlin` для `tokens:plasma.homeds.compose` (основная библиотека), `compileDebugUnitTestKotlin` (golden-тест), `:app:compileDebugKotlin` (сэндбокс), `:integration:compileDebugKotlin`, `:docs:compileDebugKotlin` — ВСЕ чисто. `verifyRoborazziDebug`/`recordRoborazziDebug`/detekt/spotless НЕ запускались по прямой просьбе пользователя в этом раунде. + - **Честная оговорка**: это крупная геометрическая правка (новый `Path.transform(Matrix)`-путь для 3 из 4 `placement`, полностью новая ветка `UpsellBubbleAlignment.Center`, полностью новая layout-логика для `Start`/`End`), которую НЕ УДАЛОСЬ визуально проверить — только `Top`+`Start`/`End` (2 из 12 комбинаций) — это буквально прежнее, уже провалидированное поведение, идущее через тот же код без Matrix-трансформации. Остальные 10 комбинаций (в т.ч. `Bottom`, `Start`, `End`-placement и любой `Center`-alignment) технически новые и нуждаются в проверке на реальном устройстве/эмуляторе, прежде чем считать раздел завершённым. +- [x] 31.8 **Баг-репорт с реального устройства и фикс** — пользователь: «проверил на устройстве, alignment работает корректно, placement top и start тоже работают, а вот bottom и end выглядят некорректно, пропадает бэкграунд в этих режимах». Ровно совпадает с честной оговоркой 31.7 — `Bottom`/`End` не были провалидированы. + - **Причина**: `placementMatrix` (`UpsellBubbleShape.kt`) писала перенос (translation) в СТОЛБЕЦ 3 матрицы (`matrix[1,3]` для `Bottom`, `matrix[0,3]` для `End`), по аналогии с учебной записью `[R|t]`/`matrix * point`. Реально `androidx.compose.ui.graphics.Matrix.map(Offset)` умножает точку СТРОКОЙ СПРАВА (`result = point · matrix`): `x' = m[0,0]·x + m[1,0]·y + m[3,0]`, `y' = m[0,1]·x + m[1,1]·y + m[3,1]` — перенос лежит в СТРОКЕ 3 (`m[3,0]`/`m[3,1]`), что подтверждается именованными константами `Matrix.TranslateX`/`TranslateY` (индексы 12/13 плоского массива = `[3,0]`/`[3,1]` при раскладке `row*4+column`) и их мостом к `android.graphics.Matrix.MTRANS_X`/`MTRANS_Y` (`AndroidMatrixConversions.android.kt`). Из-за неверной ячейки перенос физически не применялся — `Path` трансформировался в координаты за пределами клипованного `Box`, визуально это выглядело как «пропавший фон». `Start` (тоже нетривиальная матрица, но БЕЗ переноса — только своп осей) внешне «работал» случайно: его матрица симметрична относительно перестановки строка/столбец, поэтому перепутанная конвенция индексов не меняла результат для него одного. + - **Фикс**: `Bottom` — `this[1,3]` → `this[3,1]` (сам флип `this[1,1]=-1f` был верен изначально). `End` — пересобрана с нуля под верную формулу: `this[0,0]=0f, this[0,1]=1f, this[1,0]=-1f, this[1,1]=0f, this[3,0]=realSize.width` (было: `this[0,1]=-1f, this[0,3]=width, this[1,0]=1f` — неверны и знак, и ячейка переноса). KDoc у `placementMatrix` переписан с указанием верной формулы и константами-доказательством. + - **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin` — чисто. Визуальная проверка на устройстве — за пользователем (тесты по-прежнему не запускались по стоящей просьбе). + - Пользователь подтвердил фикс на устройстве («теперь работает, отлично») — раздел 31 закрыт. + +## 32. Placement/Alignment — режим автовыбора по доступному месту + +Пользователь: «давай добавим режим автовыбора направления» — вторая из двух заранее оговорённых (раздел 31) стратегий размещения, отложенная на отдельный раунд. Перед реализацией уточнено у пользователя (`AskUserQuestion`): (1) как включать автовыбор в публичном API — выбрано добавить `UpsellBubblePlacement.Auto` в СУЩЕСТВУЮЩИЙ enum (не отдельный флаг/sealed-режим) ради простоты и совместимости с уже готовой Story-генерацией enum-контролов, с явным принятием побочного эффекта («enum-набор станет чуть менее чистым» — `Auto` семантически не сторона, а стратегия выбора); (2) автовыбор подбирает И `placement`, И `alignment` вместе (не только сторону) — выбрано, чтобы тело не вылезало за границы хоста по обеим осям, а не только по оси размещения. + +- [x] 32.1 **`UpsellBubblePlacement.Auto`** (`UpsellBubbleState.kt`) — новое значение enum'а, с KDoc: автовыбор по свободному месту в границах `UpsellBubbleHost`, `alignment`-параметр в этом режиме игнорируется (тоже подбирается автоматически), значение — чистая стратегия выбора, которая НИКОГДА не должна доходить до геометрии/layout. +- [x] 32.2 **Разрешение `Auto` — РОВНО В ОДНОЙ ТОЧКЕ** (`UpsellBubbleTrigger.kt`) — в месте конструирования `UpsellBubbleOverlayEntry` (при каждом появлении бабла, не один раз при композиции — так же, как настоящие popover'ы пересчитывают сторону при каждом открытии). Дальше по системе (`UpsellBubbleShape.kt`, `UpsellBubbleHost.kt`) `UpsellBubblePlacement` гарантированно один из 4 конкретных вариантов — тип не сужается (в Kotlin нет способа исключить значение enum'а из типа без обёртки), поэтому все ~11 exhaustive `when`, ранее не знавших про `Auto`, получили общий `Auto -> unresolvedUpsellBubbleAutoPlacementError()` — единая точка отказа с понятным сообщением, если инвариант вдруг нарушится (баг, а не штатный путь). +- [x] 32.3 **Алгоритм** (`resolveAutoUpsellBubblePlacement`, `UpsellBubbleShape.kt`) — меряет свободное место как расстояние от круга-якоря до каждой из 4 границ хоста (сырые пиксели, БЕЗ учёта фактического размера тела — тот известен только после измерения `content`-слота, т.е. уже после выбора стороны; та же эвристика, что и `flip`-мидлвары в popover-библиотеках, не гарантия отсутствия наложения). Выбирает `placement` = сторона с максимумом свободного места; вдоль ПЕРПЕНДИКУЛЯРНОЙ оси выбирает `alignment`, раздувая тело в сторону, где места больше (`Start` держит круг у начала оси, тело растёт к концу — см. соответствие в `upsellBubbleBadgeAlignment`), либо `Center`, если разница мест по обеим сторонам несущественна (порог — доля от большего значения, `AUTO_ALIGNMENT_CENTER_THRESHOLD_RATIO = 0.15f`, чтобы не "дёргаться" между Start/End при почти равном месте). RTL учтён через `layoutDirection` (физическое лево/право → логическое Start/End). Вырожденный/неизмеренный размер хоста (первый кадр до layout) — фолбэк на `Top`+`End` (прежнее поведение по умолчанию). +- [x] 32.4 **Story** (`UpsellBubbleStory.kt`) — доп. изменений не потребовалось: `enumProperty`/`StateTransformer` уже генерируются через `enumValues()`/`E.valueOf(...)` рефлективно (не по фиксированному списку кейсов), `Auto` автоматически появился в списке выбора `placement` в сэндбоксе. Обновлён только KDoc `HomeDsUpsellBubbleUiState`. +- [x] 32.5 **Обновлены** `docs/override-docs/.../UpsellBubbleUsage.md` (описание режима `Auto`), KDoc `UpsellBubbleTrigger`/`UpsellBubblePlacement`. +- [x] 32.6 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Визуальная проверка `Auto` на реальном устройстве/эмуляторе — не выполнена, за пользователем. +- [x] 32.7 **Story: выбор карточки-хоста триггера** (`UpsellBubbleStory.kt`) — по вопросу пользователя («как проверить `Auto` — выбрать индекс карточки?»): раньше карточка с триггером была захардкожена (`UPSELL_CARD_INDEX = 1`, всегда в глубине сетки — свободного места хватало со всех 4 сторон, разница между `Auto` и любым ручным `placement` была не видна). Добавлено поле `cardIndex: Int` в `HomeDsUpsellBubbleUiState` (зажимается в `0..DEVICE_CARD_COUNT-1` на месте использования, а не в самом стейте — стейт может временно держать вне-диапазонное значение из поля ввода) — двигает триггер по сетке 3 колонки × 10 строк, к любой из 4 границ `UpsellBubbleHost` (первая строка/столбец, последняя видимая строка/столбец), чтобы `Auto` реально должен был выбирать разные стороны в зависимости от положения. Доп. кода в Story не потребовалось — `Int`-поля через `@StoryUiState` уже поддержаны KSP-генератором `PropertiesProducer`/`StateTransformer` (`Property.IntProperty`/`valueString.toInt()`), проверено чтением сгенерённых файлов. +- [x] 32.8 **Story: номер каждой карточки** (`UpsellBubbleStory.kt`) — по правке пользователя: индекс карточки написан крупным белым текстом по центру каждой карточки сетки, чтобы подбирать `cardIndex` по картинке, не считая карточки вручную. + +## 33. Раздельные отступы контента по 4 сторонам вместо единого `contentPadding` + +Пользователь: «убери contentPadding, добавь paddingStart, paddingEnd, paddingTop, paddingBottom (8, 8, 6, 8 соответственно)» — единого симметричного отступа недостаточно, макет требует разных отступов по каждой стороне (в частности верхний меньше остальных). + +- [x] 33.1 **`UpsellBubbleStyle.kt`** — `UpsellBubbleDimensions.contentPadding: StatefulValue` заменён на 4 отдельных свойства `paddingStart`/`paddingEnd`/`paddingTop`/`paddingBottom: StatefulValue` (логические, а не физические лево/право — зеркалируются под RTL, как и остальные `start`/`end` в этом компоненте). `UpsellBubbleDimensionsBuilder` и `DefaultUpsellBubbleDimensions`/`Builder` обновлены симметрично (по 2 метода-оверлоада на каждое свойство, как у остальных размеров). Фолбэк-дефолты в `Builder.build()`, если стиль явно их не задал — `8.dp`/`8.dp`/`6.dp`/`8.dp` (запрошенные значения). +- [x] 33.2 **`UpsellBubbleHost.kt`** (`UpsellBubbleBody`) — локальный `contentPadding` теперь `PaddingValues(start=, end=, top=, bottom=)`, собранный из 4 новых токенов стиля, вместо `Dp` из одного токена; передаётся в `.padding(contentPadding)` там же, где и раньше (сигнатура `Modifier.padding` уже принимает `PaddingValues`, изменений в самой цепочке модификаторов не потребовалось). +- [x] 33.3 **Обновлены все потребители старого `contentPadding(Dp)`** — `UpsellBubbleStyles.kt` (вариация `Default`, теперь `paddingStart(8.dp)`/`paddingEnd(8.dp)`/`paddingTop(6.dp)`/`paddingBottom(8.dp)` — те же значения, что и фолбэк-дефолт), `docs/samples/UpsellBubbleSamples.kt` (иллюстративный сэмпл стиля, те же значения), `ComposeUpsellBubbleScreenshotTest.kt` (обновлён только текст комментария, упоминавший `contentPadding` — сам тест не ссылался на метод API напрямую). +- [x] 33.4 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Значения `8/8/6/8` — меньше прежнего симметричного `12/12/12/12`, визуальную проверку итогового вида (текст ближе к краям тела) стоит сделать на устройстве/эмуляторе. + +## 34. Перегрузка `UpsellBubbleTrigger(text, progress, ...)` без content-слота — основной сценарий использования + +Пользователь: «хорошо что ты сделал вариант со слотом, но это не основной вариант использования. нам нужно сделать перегрузку без слота с возможностью задавать текст и иконку шеврона. так же нужно добавить прогресс. два прямоугольника с разрывом между ними. положение разрыва как раз и показывает прогресс. через стиль нужно задавать высоту прогресса, цвет трека и цвет значения прогресса (brush). в новой перегрузке так же нужно уметь задавать само значение прогресса (0..1)». Мидтёрн-правка пользователя: иконка-шеврон в Figma называется `DisclosureRightOutline`, а не `Chevron*` — использован `SddsIcons.DisclosureRightOutline16`. + +- [x] 34.1 **Новая перегрузка `UpsellBubbleTrigger`** (`UpsellBubbleTrigger.kt`) — параметры `text: String`, `progress: Float`, `chevronIcon: ImageVector = SddsIcons.DisclosureRightOutline16` вместо `content`-слота; остальные параметры (`hasNewOffer`/`onExpandedClick`/`placement`/`alignment`/`style`/`onDismissed`/`contentDescription`) идентичны основной перегрузке. Делегирует основной (content-слотовой) перегрузке — сама не дублирует стейт-машину/анимации, только собирает стандартное содержимое (`UpsellBubbleTriggerTextContent`, private) и передаёт как `content`. Иконка найдена через `sdds-core/icons-compose` (`SddsIcons.DisclosureRightOutline16`, семейство "иконка ведёт куда-то дальше", уже используемое в этом дизайн-системе для `Cell`/`ListItem`'s `disclosureIcon` — не спутано со смежным семейством `Chevron*`, изначально выбранным по слову пользователя "шеврон", но исправленным по прямой правке на точное имя из Figma). +- [x] 34.2 **Новая зависимость модуля** (`tokens/plasma.homeds.compose/build.gradle.kts`) — `implementation(iconsCompose.sdds.icons.compose)` (артефакт `io.github.salute-developers:sdds-icons-compose`, в этой сборке подменяется на локальный проект `:sdds-core:icons-compose` через dependency substitution — подтверждено `./gradlew :tokens:plasma.homeds.compose:dependencies`). Раньше модуль зависел только от XML-иконок (`icons.sdds.icons`), не от compose-векторных. +- [x] 34.3 **Прогресс-бар** (`UpsellBubbleProgress.kt`, новый файл, `internal fun UpsellBubbleProgress`) — два прямоугольника с фиксированным (не стилизуемым, по прямому запросу — стилизуемы только высота/цвета) зазором между ними; позиция ЦЕНТРА зазора линейно зависит от `progress.coerceIn(0f,1f)`, но зажимается в `[gapWidth/2, width-gapWidth/2]`, чтобы зазор целиком помещался в границы при `progress`, близком к 0/1 (прямоугольник соответствующей стороны в этом случае схлопывается до нулевой ширины, а не вылезает за край). НЕ через `drawOutline`/`CornerBasedShape`, как `ProgressBar`/`Slider` из `sdds-core/uikit-compose` (оба рисуют сплошной индикатор ПОВЕРХ сплошного трека — так зазор физически не изобразить, эти компоненты никогда не оставляют трек невидимым под индикатором) — здесь трек и заливка рисуются раздельными `drawRect`, физически не пересекаясь. Реализовано как `Modifier.drawWithCache { onDrawBehind {...} }` (тот же идиом, что и остальная отрисовка в этом компоненте), а не `Canvas`. +- [x] 34.4 **Новые токены стиля** (`UpsellBubbleStyle.kt`) — `UpsellBubbleDimensions.progressHeight: StatefulValue` (фолбэк-дефолт `4.dp`, по аналогии с толщиной трека/индикатора у `ProgressBar`/`Slider` этой дизайн-системы — отдельного дизайн-спека на прогресс-бар апсейл-бабла ещё нет), `UpsellBubbleColors.progressTrackBrush`/`.progressValueBrush: StatefulValue` (фолбэк-дефолты — полупрозрачный/сплошной белый, по аналогии с `borderBrush`/`textColor`). Именование `track`/`value` — по буквальным словам пользователя («цвет трека», «цвет значения прогресса»), а не `track`/`indicator`, как у `Slider`/`ProgressBar` в `sdds-core/uikit-compose` (сверено через `Explore`-агента — там наименование `indicatorBrush`, но пользователь явно сказал «значение», решено не расходиться с его терминологией). Вариация `UpsellBubble.Default` (`UpsellBubbleStyles.kt`) обновлена явно (по установленной в разделе 31/33 конвенции «вариация Default самодокументируется, ничего не наследует молча») — трек переиспользует уже используемый в этой же вариации `outlineOnDarkTransparentPrimary`, значение — `textOnDarkPrimary`. +- [x] 34.5 **Текст/иконка НЕ дублируют источник цвета** — `UpsellBubbleTriggerTextContent` не читает `style.colors.textColor` напрямую для `Text`/`Icon`, а полагается на уже накинутые `LocalTextBrushProducer`/`LocalTintBrushProducer` (тот же провайдер, что и у произвольного content-слота, см. `UpsellBubbleBody` в `UpsellBubbleHost.kt`) — цвет текста/иконки автоматически совпадает с тем, что получил бы контент в content-слотовой перегрузке. +- [x] 34.6 **Story** (`UpsellBubbleStory.kt`) — `Content()` переведена на новую перегрузку (`text`/`progress` вместо `content`-слота с `Text(...)`), добавлено поле `progress: Float` в `HomeDsUpsellBubbleUiState` (дефолт `0.5f`) — `Float`-поля через `@StoryUiState` уже поддержаны KSP-генератором (`Property.FloatProperty`), доп. кода не потребовалось (проверено чтением сгенерённых файлов, как и с `cardIndex`/`Int` в раунде 32.7). `Preview()` оставлена на content-слотовой перегрузке — чтобы обе перегрузки оставались покрыты хоть где-то в сэндбоксе. +- [x] 34.7 **Docs** (`UpsellBubbleUsage.md`, `docs/samples/UpsellBubbleSamples.kt`) — раздел "Содержимое бабла" переписан: новая перегрузка описана как основной сценарий (с примером и описанием зазора-прогресса), content-слот — как отдельная возможность для произвольного контента. Сэмпл `UpsellBubble_Default` (генерирует скриншот на странице документации) переведён на новую перегрузку. +- [x] 34.8 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + - **Честная оговорка**: раскладка `UpsellBubbleTriggerTextContent` (Row текст+шеврон, отступ, прогресс-бар под ней) и все geometry-константы прогресс-бара (высота, ширина зазора, отступы) — не сверены с макетом (отдельного дизайн-спека на этот вариант ещё нет), подобраны на глаз по образцу остальных таких же констант в этом компоненте. Требуется визуальная проверка на устройстве/эмуляторе, особенно поведение зазора у самых краёв (`progress`≈0/1) и цвета по умолчанию (полупрозрачный/сплошной белый) на реальном `bodyBrush` фоне. +- [x] 34.9 **Мидтёрн-добавление пользователя**: «не забудь добавить отступ прогресса в стиль (отступ от текста до прогресса), добавить hasProgress в новую перегрузку бабла». + - Отступ между строкой текст+шеврон и прогресс-баром вынесен из приватной константы (`UPSELL_BUBBLE_PROGRESS_SPACING_DP`, была захардкожена) в новый токен стиля `UpsellBubbleDimensions.progressSpacing: StatefulValue` (фолбэк-дефолт `8.dp` — то же значение, что было раньше константой). Отступ шеврона (`UPSELL_BUBBLE_CHEVRON_SPACING_DP`) остался приватной константой — пользователь просил вынести в стиль именно отступ ДО прогресса, не отступ до шеврона. + - Новый параметр `hasProgress: Boolean = true` у перегрузки с `text`/`progress` — при `false` `UpsellBubbleTriggerTextContent` не рисует ни прогресс-бар, ни отступ под него (не просто прячет с сохранением места — самого `Spacer`/`UpsellBubbleProgress` в дереве нет). `progress` при этом не читается. + - `UpsellBubble.Default` (`UpsellBubbleStyles.kt`) обновлена явно — `progressSpacing(8.dp)`, та же конвенция, что и у остальных размеров вариации. + - `HomeDsUpsellBubbleUiState` (сэндбокс) — добавлено поле `hasProgress: Boolean = true`, проброшено в `UpsellBubbleTrigger`. + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. +- [x] 34.10 **Баг-репорт + правка формы, до визуальной проверки на устройстве**: «сейчас прогресс растягивает на максимум бабл - это неправильно. давай добавим в стиль рекомендуемую ширину бабла - 100 dp. И еще края прогресса должны быть круглыми а не прямоугольными». + - **Причина растяжения**: `Text(modifier = Modifier.weight(1f))` внутри `Row` и `UpsellBubbleProgress(modifier = Modifier.fillMaxWidth())` — оба требуют ОГРАНИЧЕННУЮ ширину родителя, чтобы посчитать своё место/заполнение; без явного `width` где-либо выше по дереву тело подстраивалось под МАКСИМАЛЬНО доступную ширину (фактически ширину экрана внутри `UpsellBubbleHost`), а не под ширину текста. + - **Фикс**: новый токен `UpsellBubbleDimensions.bodyRecommendedWidth: StatefulValue` (фолбэк-дефолт `100.dp`), применяется ТОЛЬКО в `UpsellBubbleTriggerTextContent` (`Modifier.width(...)` на корневом `Column`) — перегрузка с произвольным content-слотом не затронута, там ширина по-прежнему определяется самим содержимым, как и раньше. `UpsellBubble.Default` обновлена явно (`bodyRecommendedWidth(100.dp)`). + - **Скруглённые торцы**: `UpsellBubbleProgress.kt` — `drawRect` заменён на `drawRoundRect` с `cornerRadius = высота/2` (полностью скруглённый, "пилюльный" торец) для ОБОИХ прямоугольников (заливка и трек); радиус дополнительно зажат сверху половиной ширины САМОГО сегмента (`coerceAtMost(segmentWidth/2f)`) — без этого у сегмента, почти схлопнутого зазором до нулевой ширины при `progress`≈0/1, скругление визуально "вылезало" бы за пределы своего прямоугольника. + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. Визуальная проверка на устройстве/эмуляторе (фактическая ширина тела с реальным текстом, вид скруглённых торцов у краёв прогресса) не выполнена. +- [x] 34.11 **Цвета прогресса сверены с Figma** (`UpsellBubbleStyles.kt`, вариация `Default`): «цвета прогресса ты взял не из фигмы, поправь стиль: трэк Text&Icons/Default/General/Tertiary (найди подходящий токен цвета), значение #D0FF77F5 (ставь хекс, тут альфа канал в конце, а в андроид он вроде в начале)». + - **Трек** — заменён с временного `outlineOnDarkTransparentPrimary` на именованный токен темы, найден по той же связке именования, что уже использована для `borderColor` в этой же вариации (Figma `Outlines/Default/General/Transparent/Primary` → код `outlineOnDarkTransparentPrimary`, т.е. "Default/General" из пути Figma не попадает в имя токена, вместо него — суффикс `OnDark`) — изначально применил ту же связку к `Text&Icons/Default/General/Tertiary` → `textOnDarkTertiary`; пользователь затем поправил напрямую в файле на `textDefaultTertiary` (более буквальное соответствие пути Figma, где "Default" сохранён явно) — принято как есть, не откатывалось. + - **Значение** — заменено с `textOnDarkPrimary` на сырой `Color(0xF5D0FF77)`. Пользователь дал hex в Figma-порядке RGBA (альфа последним байтом — `#D0FF77F5`), Android/Compose `Color(Long)` ждёт ARGB (альфа первым) — байты переставлены на `0xF5D0FF77`. Тот же зелёный `D0FF77`, что уже используется в этом же файле для Inner shadow/`UPSELL_BUBBLE_INNER_GLOW_COLOR` (`UpsellBubbleInnerGlowPainter.kt`) — не совпадение, фирменный акцентный оттенок макета. + - Фолбэк-дефолты в `DefaultUpsellBubbleColors.Builder` (`UpsellBubbleStyle.kt`, срабатывают только для КАСТОМНОГО стиля без явно заданных `progressTrackColor`/`progressValueColor`, у builder нет доступа к теме) НЕ тронуты — остались generic-плейсхолдерами (`Color.White.copy(alpha=0.24f)`/`Color.White`), тот же паттерн, что и у остальных цветов в этом builder (`bodyBrush ?: Color.Green`, и т.п.) — точные Figma-значения несёт именно вариация `Default`, как и для всех остальных цветов компонента. + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. +- [x] 34.12 **Форма прогресса поправлена, разрыв вынесен в стиль** — «посмотрел. не совсем корректно. только крайние концы должны быть закругленные, а те, что у указателя значения - острые. и еще, давай через стиль задавать толщину указателя значения прогресса, иными словами толщину разрыва между двумя частями прогресса». + - **Асимметричное скругление** (`UpsellBubbleProgress.kt`) — единого `drawRoundRect(cornerRadius=)` (скругляет все 4 угла одинаково) недостаточно: нужны разные радиусы по углам одного прямоугольника (внешние — скруглены, обращённые к разрыву — острые). Путь строится вручную через `androidx.compose.ui.geometry.RoundRect(rect, topLeft=, topRight=, bottomRight=, bottomLeft=)` + `Path().addRoundRect(...)` — тот же приём, что и у `bodyNearCornerRadius` в `UpsellBubbleShape.kt` (тот же класс задачи: не все углы прямоугольника скруглены одинаково). Для сегмента-заливки (слева) скруглены `topLeft`/`bottomLeft`, для сегмента-трека (справа) — `topRight`/`bottomRight`; вторая пара по 0 в обоих случаях. + - **Толщина разрыва — новый токен стиля** — `UpsellBubbleDimensions.progressPointerThickness: StatefulValue` (фолбэк-дефолт `4.dp` — то же значение, что раньше было приватной константой `UPSELL_BUBBLE_PROGRESS_GAP_WIDTH_DP`, теперь удалённой). Название — по буквальным словам пользователя («толщина указателя значения прогресса», не «ширина зазора») — пользователь переосмысливает разрыв как САМ указатель текущего значения, а не просто промежуток между сегментами; сохранено в KDoc/именовании токена. `UpsellBubble.Default` обновлена явно (`progressPointerThickness(4.dp)`). + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. Визуальная проверка на устройстве/эмуляторе не выполнена. + - Мидтёрн-правка пользователя напрямую в файле (не через меня): `UpsellBubble.Default` — `progressPointerThickness(4.dp)` → `progressPointerThickness(2.dp)`. Принято как есть, не откатывалось. Фолбэк-дефолт в `DefaultUpsellBubbleDimensions.Builder` (`UpsellBubbleStyle.kt`) остался `4.dp` — он про кастомный стиль без явного значения, вариации `Default` не касается. +- [x] 34.13 **Переименование `bodyRecommendedWidth` → `width`** — «bodyRecommendedWidth давай переименуем просто в width». Переименовано во всех местах: `UpsellBubbleDimensions.width`/`UpsellBubbleDimensionsBuilder.width(...)` (`UpsellBubbleStyle.kt`, интерфейс + builder + impl), вызов в `UpsellBubble.Default` (`UpsellBubbleStyles.kt`), чтение в `UpsellBubbleTriggerTextContent` (`UpsellBubbleTrigger.kt`), упоминание в `UpsellBubbleUsage.md`. Значение (`100.dp`) не менялось. + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. +- [x] 34.14 **Типографика текста через стиль + провайдится и в content-слот** — «также давай через стиль задавать типографику текста, и кстати в варианте со слотом будем провайдить этот стиль тоже. и цвет текста тоже. стиль body.xs.normal. цвет текста text.ondark.primary». + - **Новое свойство `UpsellBubbleStyle.textStyle: StatefulValue`** (`UpsellBubbleStyle.kt`) — top-level на самом `UpsellBubbleStyle` (не внутри `colors`/`dimensions` — типографика не укладывается ни в цвета, ни в Dp-размеры), с builder-методом `textStyle(TextStyle)`/`textStyle(StatefulValue)` прямо на `UpsellBubbleStyleBuilder` (не через вложенный `.colors{}`/`.dimensions{}}`-лямбда билдер, т.к. это одно поле, а не под-объект) — по прецеденту `NumberPanelStyle.valueStyle`/`separatorStyle`/`fractionStyle` в этом же модуле (та же форма: top-level `StatefulValue` поле+builder, не вложенное). Фолбэк-дефолт — `TextStyle.Default`. + - **Провайдится и в content-слот, не только в свою перегрузку** (`UpsellBubbleHost.kt`, `UpsellBubbleBody`) — `LocalTextStyle provides style.textStyle.getDefaultValue()` добавлен в ТОТ ЖЕ `CompositionLocalProvider`, что уже оборачивал `entry.content()` для `LocalTintBrushProducer`/`LocalTextBrushProducer` (цвет) — тем же способом, без нового отдельного провайдера. `UpsellBubbleTriggerTextContent`'s собственный `Text(text=text, ...)` НЕ получил явный параметр `style=` — читает `LocalTextStyle.current` автоматически (дефолт самого `Text`), так что один и тот же провайдер обслуживает и content-слот, и внутреннюю реализацию. + - **`UpsellBubble.Default`** (`UpsellBubbleStyles.kt`) — `.textStyle(PlasmaHomeDsTheme.typography.bodyXsNormal)` (Figma-токен "body/xs/normal", найден по прямому совпадению именования `{категория}{Размер}{Вес}` в `PlasmaHomeDsTypography.kt`). `textColor` (`textOnDarkPrimary`) уже совпадал с запрошенным "text.ondark.primary" ДО этой правки (задан ещё в самой первой вариации `Default`) — не менялся, только подтверждён. + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Визуальная проверка на устройстве/эмуляторе не выполнена. +- [x] 34.15 **Шеврон — inline-контент текста, а не соседний `Icon` в `Row`** — «ок. только иконка шеврона должна быть привязана к последнему слову на последней строке текста. она должна центрироваться по lineHeight. и чуть смещена в сторону текста (заезжает на текст) на 3dp. и нужно добавить этот параметр в стиль тоже». + - **Причина переделки**: прежняя раскладка (`Row(Text(weight(1f)), Spacer, Icon)`) при переносе текста на несколько строк центрировала иконку по высоте ВСЕГО блока текста (Row размер по максимальному ребёнку), а не подле последнего слова последней строки — не то, что нужно. + - **Реализация** (`UpsellBubbleTriggerTextContent`, `UpsellBubbleTrigger.kt`) — иконка встроена в сам `AnnotatedString` через `androidx.compose.foundation.text.appendInlineContent`/`InlineTextContent`/`androidx.compose.ui.text.Placeholder` (штатный Compose-механизм "иконка как часть текстового потока, переносится вместе со строкой") вместо соседнего `Icon` в `Row`. Размер плейсхолдера — собственные `chevronIcon.defaultWidth`/`.defaultHeight` (свойства `ImageVector`), переведённые в `sp` через `with(density) { dp.toSp() }` (Placeholder требует `TextUnit`, не `Dp`) — новый стилевой токен под размер иконки не заводился, пользователь не просил. + - **Центрирование по lineHeight** — `Placeholder.placeholderVerticalAlign = PlaceholderVerticalAlign.Center` (центр плейсхолдера относительно центра ВСЕЙ строки, с учётом line-spacing) — НЕ `TextCenter` (центрирует по метрикам глифов текста, без учёта line-spacing) — разница задокументирована в KDoc `Placeholder.kt`, подтверждена прямым чтением исходников Compose (не угадана). + - **Наезд на текст — новый токен стиля** `UpsellBubbleDimensions.chevronTextOverlap: StatefulValue` (фолбэк-дефолт `3.dp`, вариация `Default` — тоже `3.dp` явно). Между текстом и inline-иконкой изначально НЕТ зазора (плейсхолдер вплотную) — иконка внутри своего плейсхолдера дополнительно сдвигается `Modifier.offset(x = -chevronTextOverlap)`, физически заезжая на последний символ текста. `Modifier.offset` (не `absoluteOffset`) сам зеркалируется под RTL — "в сторону текста" остаётся верным без ручной RTL-развилки. + - Убраны: `Row`/`Spacer`(отступ шеврона)/приватная константа `UPSELL_BUBBLE_CHEVRON_SPACING_DP` — полностью заменены inline-подходом, отступа между текстом и шевроном больше не существует как отдельного понятия. + - Проверка: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — чисто. Тесты/detekt/spotless не запускались. Визуальная проверка на устройстве/эмуляторе (в т.ч. многострочный текст — главный сценарий этой правки) не выполнена. + +## 35. Токен внутренней тени в стиле — параметры вместо приватных констант + +Пользователь: «давай добавим токен тени в стиль. сейчас самого токена в дизайн системе нет, но давай его соберем вручную в стиле, и потом значение shadow appearance заюзаем внутри компонента - достанем оттуда все необходимые параметры. можно так сделать?». Подтверждено — да, можно и сделано: не как рендер-механизм (`Modifier.shadow(ShadowAppearance)` уже пробовали для inner shadow и откатили, см. `tasks.md`/KDoc `UpsellBubbleInnerGlowPainter`), а как СТРУКТУРА ДАННЫХ для параметров, читаемых самописным рендером. + +- [x] 35.1 **`UpsellBubbleStyle.shadow: StatefulValue`** (`UpsellBubbleStyle.kt`) — top-level свойство (по тому же прецеденту, что и `textStyle`, — не укладывается ни в `colors` (не `Brush`), ни в `dimensions` (не `Dp`)), с builder-методами `shadow(ShadowAppearance)`/`shadow(StatefulValue)` на `UpsellBubbleStyleBuilder`. Переиспользует УЖЕ СУЩЕСТВУЮЩИЙ в `sdds-core/uikit-compose` тип `ShadowAppearance`/`ShadowLayer` (`color`/`offset`/`spreadRadius`/`blurRadius`/`fallbackElevation`) — новый тип не заводился. Фолбэк-дефолт в билдере — те же значения, что раньше были приватными константами `UPSELL_BUBBLE_INNER_GLOW_COLOR`/`UPSELL_BUBBLE_INNER_GLOW_BLUR_DP` (`Color(0xFFD0FF77).copy(alpha=0.6f)`, `blurRadius=4.dp`, `offset`/`spreadRadius`=0 — по Figma-спеке). +- [x] 35.2 **`UpsellBubbleInnerGlowPainter.innerGlowLayer()`** (`UpsellBubbleInnerGlowPainter.kt`) — новая `internal fun UpsellBubbleStyle.innerGlowLayer(): ShadowLayer? = shadow.getDefaultValue().layers.firstOrNull()`, единая точка извлечения первого слоя тени для всех 3 мест отрисовки glow. `null`, если стилем явно задан пустой `ShadowAppearance` — вызывающая сторона в этом случае просто не рисует glow (не кидает и не подставляет дефолт молча). +- [x] 35.3 **Все 3 места отрисовки glow переведены на токен стиля** — `UpsellBubbleTrigger.kt` (заливка локального/свёрнутого бейджа), `UpsellBubbleHost.kt` (заливка развёрнутого тела), `UpsellBubbleTriggerIcon.kt` (внутренний glow рамки-обводки на углу тела). Последний не принимал `style` вообще (только уже резолвленные `Brush`-параметры от вызывающей стороны, тот же паттерн, что и у `tint`/`backgroundBrush`/`borderBrush`) — добавлен новый параметр `glowLayer: ShadowLayer?`, резолвится ОБОИМИ вызывающими сторонами (`UpsellBubbleTrigger.kt`/`UpsellBubbleHost.kt`) через `style.innerGlowLayer()` и прокидывается, а не читается из style напрямую внутри — сохраняет существующую границу ответственности компонента (не принимает `UpsellBubbleStyle` целиком). +- [x] 35.4 **`UPSELL_BUBBLE_INNER_GLOW_COLOR`/`UPSELL_BUBBLE_INNER_GLOW_BLUR_DP` удалены** (`UpsellBubbleInnerGlowPainter.kt`) — сама техника рендера (`BlurMaskFilter` на принудительно программном Bitmap/Canvas) и её история (3 отклонённых альтернативы, документированы в KDoc `UpsellBubbleInnerGlowPainter`) не изменились, изменился только источник `color`/`blurRadius` — раньше приватные константы, теперь параметры стиля. +- [x] 35.5 **`UpsellBubble.Default`** (`UpsellBubbleStyles.kt`) — `.shadow(ShadowAppearance(listOf(ShadowLayer(color=Color(0xFFD0FF77).copy(alpha=0.6f), offset=DpOffset.Zero, spreadRadius=0.dp, blurRadius=4.dp))))`, явно (та же конвенция, что и у остальных токенов вариации). KDoc вариации переписан — раньше объяснял, ПОЧЕМУ эффекта нет в стиле (не был вынесен), теперь объясняет, ПОЧЕМУ он вынесен именно так (структура данных, не рендер-механизм) и откуда взяты конкретные значения (blurRadius=4 не 8 — по экспортированному SVG-фильтру `feGaussianBlur stdDeviation="4"`, Figma UI-значение "8" — это 2×sigma; альфа 0.6 не формальные 80% — подобрано на глаз под конкретную технику рендера). +- [x] 35.6 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Визуальная проверка на устройстве/эмуляторе не выполнена — чисто рефакторинг источника параметров (те же значения, тот же рендер-путь), регрессии не ожидается, но не подтверждено эмпирически. + +## 36. Аудит и завершение областей нажатия + +Пользователь: «теперь давай проверим области нажатия. нам нужно предоставлять пользователю лямбды для оработки нажатий на: 1. область тела с текстом и прогрессом 2. иконка закрытия (крестик в круге) 3. нажатие на сам триггер (при этом открывать бабл мы будем сами, но у пользователя должна быть возможность слушать это событие)». + +- [x] 36.1 **Аудит (1) и (2) — уже реализованы, только проверены**: (1) `onExpandedClick` → `entry.onBodyClick` → `.clickable(onClick=)` на ВНЕШНЕМ `Box` в `UpsellBubbleBody` (`UpsellBubbleHost.kt`), который является родителем и content-области (текст+прогресс/слот), и бейджа-крестика — клик по бейджу перехватывается его СОБСТВЕННЫМ `.clickable` раньше, чем всплывёт до внешнего (стандартное поведение `clickable` — потребляет жест), так что области (1) и (2) не пересекаются и не задваивают колбэки. (2) `onDismissed` → `entry.onCloseClick` → `.clickable(onClick=)` на Box бейджа-крестика (`.align(badgeAlignment).size(circleSize)`). Оба подтверждены чтением кода, изменений не потребовалось. +- [x] 36.2 **(3) — новый параметр `onTriggerClick: (() -> Unit)? = null`** (обе перегрузки `UpsellBubbleTrigger.kt`) — вызывается ВНУТРИ `if (state == UpsellBubbleState.Collapsed)` в `.clickable(onClick=)` локального (свёрнутого) бейджа, СРАЗУ ПОСЛЕ внутреннего перехода `state = Expanded` — компонент по-прежнему сам решает разворачивать бабл (правка пользователя: «открывать бабл мы будем сами»), колбэк только уведомляет, ничем не управляет и не может отменить разворот (нет возврата `Boolean`/подобного). Читается напрямую из области видимости composable-тела (НЕ через `rememberUpdatedState`, в отличие от колбэков внутри `LaunchedEffect` выше по файлу) — сам `.clickable` пересоздаётся при каждой рекомпозиции, в отличие от долгоживущего эффекта, так что устаревание значения здесь невозможно в принципе. +- [x] 36.3 **Docs** (`UpsellBubbleUsage.md`) — новый раздел "Обработка нажатий", перечисляющий все 3 колбэка одним местом с явным указанием, какая область за какой отвечает и кто ими управляет (компонент или вызывающая сторона). +- [x] 36.4 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Визуальная проверка на устройстве/эмуляторе (реальная область попадания трёх зон) не выполнена. + +## 37. Время автосворачивания — токен стиля + +Пользователь спросил (исследовательский вопрос, не решение) — «а если пользователь сам захочет открыть или закрыть бабл динамически в любой момент?». Ответ: понадобится контроллер-объект по аналогии с уже существующим `UpsellBubbleHostState` — НЕ реализовано, отложено («это просто на будущее, ненадо пока делать»). Тут же — конкретная правка: «давай только вынесем в стиль время автосворачивания». + +- [x] 37.1 **`UpsellBubbleStyle.autoCollapseDelayMillis: Int`** (`UpsellBubbleStyle.kt`) — top-level свойство, НЕ `StatefulValue` — по прецеденту `SwitchStyle.animationDurationMillis`/`CheckBoxStyle.animationDuration` в `sdds-core/uikit-compose` (оба тоже длительности анимаций, оба обычный `Int`, не стейтфул — длительность не зависит от состояния интеракции). Builder-метод `autoCollapseDelay(millis: Int)` на `UpsellBubbleStyleBuilder` (без пары `StatefulValue`-оверлоадов, как у `shadow`/`textStyle` — по тому же прецеденту `SwitchStyle`/`CheckBoxStyle`, там тоже один метод). Фолбэк-дефолт в билдере — `5_000` (то же значение, что раньше было приватной константой `AUTO_COLLAPSE_DELAY_MS` в `UpsellBubbleTrigger.kt`, теперь удалённой). +- [x] 37.2 **`UpsellBubbleTrigger.kt`** — `delay(AUTO_COLLAPSE_DELAY_MS - EXPAND_DURATION_MS)` → `delay((style.autoCollapseDelayMillis - EXPAND_DURATION_MS).toLong())` (`.toLong()` — `autoCollapseDelayMillis: Int`, `delay()` принимает только `Long`, раньше `AUTO_COLLAPSE_DELAY_MS` сама была `Long`-константой). Внутри `LaunchedEffect(state) { ... }`, который перезапускается при каждой смене `state` (не долгоживущий, в отличие от `LaunchedEffect(hostState, key)` выше по файлу) — `style` читается напрямую без `rememberUpdatedState`, свежее значение гарантировано самим перезапуском эффекта. +- [x] 37.3 **`UpsellBubble.Default`** (`UpsellBubbleStyles.kt`) — `.autoCollapseDelay(5_000)`, явно (та же конвенция, что и у остальных токенов вариации). +- [x] 37.4 **Docs** (`UpsellBubbleUsage.md`) — вступительное описание компонента переписано: было "автоматически сворачивается обратно через 5 секунд" (жёстко), теперь "через `autoCollapseDelayMillis` (токен стиля, по умолчанию 5 секунд)". +- [x] 37.5 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + +## 38. Группа в сэндбоксе + баг «триггер пропадает без хоста» + +Пользователь: «ты занес компонент в новую группу Overlays, но у нас уже есть группа Overlay. давай туда и добавим. и еще я заметил что на экране со списком компонентов превью бабла работает некорректно. если нажимаешь на триггер, то он пропадает и бабл не раскрывается». + +- [x] 38.1 **Группа сэндбокса** (`PlasmaHomedsComposeCustomComponents.kt`) — `UpsellBubbleKey` заводил НОВУЮ группу `ComponentGroup("Overlays", 2)` (опечатка во множественном числе + произвольный `ordinal`), вместо использования уже существующей `"Overlay"` (`CoreComponentGroup.OVERLAY` в `integration-core/sandbox-core/.../Component.kt`, приватный enum — недоступен напрямую из другого модуля/пакета). Исправлено на переиспользование `ComponentKey.Overlay.group` (публичное поле, уже несёт правильные `displayName`/`ordinal`) — не задан вручную заново, чтобы не расходиться, если core-группы когда-нибудь переупорядочат. `ComponentGroup` — `data class`, группировка/сортировка зависит от РАВЕНСТВА ОБОИХ полей (`displayName` И `ordinal`), не только имени — опечатка в любом из двух давала бы вторую, отдельную группу с тем же видимым названием. +- [x] 38.2 **Баг: триггер пропадает без хоста, бабл не раскрывается** — репродуцируется на экране списка компонентов сэндбокса, где `UpsellBubbleStory.Preview()` намеренно рисует триггер БЕЗ `UpsellBubbleHost` (см. её собственный комментарий и KDoc `UpsellBubbleTrigger`: «без хоста триггер продолжает работать как обычная свёрнутая иконка, разворот не виден»). + - **Причина**: клик по свёрнутому триггеру безусловно переводил внутренний `state` в `Expanded`, независимо от того, есть ли хост. Локальный (свёрнутый) кружок гаснет по `alpha = if (state == Collapsed) 1f else 0f` — без хоста НЕКОМУ нарисовать заменяющий оверлей (вся регистрация в `LaunchedEffect(hostState, key)` — под условием `hostState != null`, а `hostState == null` без `UpsellBubbleHost` выше по дереву), так что свёрнутая иконка просто гасла в пустоту. + - **Фикс** (`UpsellBubbleTrigger.kt`) — `.clickable` на локальном бейдже: `if (state == Collapsed && hostState != null)` вместо `if (state == Collapsed)` — без хоста клик становится no-op, ровно как обещано в KDoc компонента (переписан, чтобы явно фиксировать это поведение, а не расходиться с фактическим). `onTriggerClick` (раздел 36) по тому же условию — без хоста разворота не происходит, уведомлять не о чем. + - Скриншот-тесты (`ComposeUpsellBubbleScreenshotTest.kt`) не задеты — там `UpsellBubbleHost` смонтирован во всех 4 тестах, `hostState` всегда не `null`. +- [x] 38.3 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Визуальная проверка на устройстве/эмуляторе (в частности — экран списка компонентов сэндбокса, группа "Overlay") не выполнена. + +## 39. Уборка API: убрать `hasNewOffer`, переименовать `UpsellBubble` → `Bubble`, сверить порядок параметров + +Пользователь: «1. уберем ненужное свойство hasNewOffer 2. переименуем UpsellBubble в Bubble 3. проверим порядок параметров в публичных composable функциях, чтобы он не отличался от общепринятого на проекте». Перед началом уточнено (`AskUserQuestion`): (1) переименовывать ли Kotlin-пакет тоже, не только идентификаторы/файлы — да, переименовать (`com.sdds.plasma.homeds.components.upsellbubble` → `...components.bubble`, аналогично `motion`/`styles/customcomponents`/`sandbox/strories`); (2) переименовывать ли `openspec/changes/add-homeds-upsell-bubble`/`specs/homeds-upsell-bubble-component` — нет, это история/трекинг фичи, не публичный API, оставлено как есть (в т.ч. этот самый `tasks.md`). + +- [x] 39.1 **Переименование `UpsellBubble` → `Bubble`** — механически, во всех 4 регистрах, встреченных в кодовой базе (`UpsellBubble`→`Bubble`, `upsellBubble`→`bubble`, `UPSELL_BUBBLE`→`BUBBLE`, `upsellbubble`→`bubble`), во всех затронутых 20 файлах (кроме openspec). `git mv` для файлов/директорий (сохраняет историю), затем `sed` по всем 4 паттернам сразу. + - **Директории (пакеты)**: `components/upsellbubble` → `components/bubble`, `motion/upsellbubble` → `motion/bubble`, `styles/customcomponents/upsellbubble` → `styles/customcomponents/bubble`, `app/.../sandbox/strories/upsellbubble` → `.../sandbox/strories/bubble`. + - **Файлы** (10 в `components/bubble`, 2 в `motion/bubble`, 2 в `styles/customcomponents/bubble`, плюс `BubbleStory.kt`, `BubbleSamples.kt`, `BubbleUsage.md`, `PlasmaHomedsBubbleVariationsCompose.kt`, `ComposeBubbleScreenshotTest.kt`) — переименованы 1:1 с внутренними типами (`UpsellBubbleTrigger.kt`→`BubbleTrigger.kt` и т.д.). + - **`ComponentKey`**: `"UpsellBubble"` → `"Bubble"` (видимое имя компонента в сэндбоксе). + - Локальные идентификаторы со словом "upsell" отдельно от "UpsellBubble" (`DEFAULT_UPSELL_CARD_INDEX`, `upsellCardIndex` в `BubbleStory.kt`) — переименованы вручную (`sed` их не тронул, т.к. не подходили ни под один из 4 паттернов) для консистентности: `DEFAULT_BUBBLE_CARD_INDEX`/`bubbleCardIndex`. + - Не тронуто: `openspec/changes/add-homeds-upsell-bubble/*` (весь этот `tasks.md` и соседние `README.md`/`design.md`/`proposal.md`/`specs/homeds-upsell-bubble-component/spec.md`) — по решению пользователя, это трекинг истории изменения, не публичный API. + - Проверка: `find`/`grep -ri upsell` по всей кодовой базе (кроме `openspec/`) — пусто. +- [x] 39.2 **Удалён `hasNewOffer: Boolean`** (обе перегрузки `BubbleTrigger.kt`) — показ/скрытие теперь целиком через монтирование/размонтирование самого `BubbleTrigger` вызывающей стороной, а не через флаг. Внутри: убран `LaunchedEffect(hasNewOffer)` целиком (раньше переводил `state` между `Hidden`/`Collapsed` в ответ на флаг) и переменная `userDismissed` (была нужна только внутри этого эффекта — стала мёртвой сразу после его удаления). Начальное значение `state` — сразу `BubbleState.Collapsed` (не `Hidden` с последующим переходом) — присутствие в дереве уже и есть сигнал "есть предложение". `BubbleState.Hidden` как состояние НЕ удалён — по-прежнему единственный путь туда — явное закрытие крестиком (`onCloseClick`), семантика этого перехода не изменилась, только убран путь НАЗАД из Hidden в Collapsed через флаг (теперь только через новый экземпляр компонента, напр. другой `key` у вызывающей стороны). Сброс `Animatable`-ов после dismiss-анимации оставлен как есть — не мёртвый код, а необходимое условие для `hostState.unregister` (см. `snapshotFlow` на `expandProgress`/`dismissAlpha`). + - Обновлены все потребители: `BubbleStory.kt` (поле `hasOffer: Boolean` в стейте сэндбокса переименовано и переведено с прокидывания параметра на условное монтирование `if (... && state.hasOffer) { BubbleTrigger(...) }`), `BubbleSamples.kt`, `BubbleUsage.md` (переписан раздел "Показ и скрытие"), `ComposeBubbleScreenshotTest.kt` (убран параметр из 3 тестов; тест `testHiddenShowsNothing` удалён целиком вместе с golden-скриншотами `testHiddenShowsNothing_{dark,light}.png` — тестировать стало нечего на уровне компонента, скрытие теперь ответственность вызывающей стороны, не behavior компонента). +- [x] 39.3 **Порядок параметров публичных composable — сверен, менять не пришлось** — сверено против прецедента в `sdds-core/uikit-compose` (`Button.kt`: `label, onClick, modifier = Modifier, value = null, style = ..., ...`; `Chip.kt`: всё опционально, `modifier` первым) — конвенция проекта: обязательные параметры (без дефолта) первыми в естественном порядке, `modifier: Modifier = Modifier` сразу после них как первый опциональный, остальные опциональные далее, обязательная trailing content-лямбда — последней. `BubbleHost`, обе перегрузки `BubbleTrigger` уже соответствовали этой конвенции ДО и ПОСЛЕ удаления `hasNewOffer` (он и был единственным обязательным параметром перед `modifier` — остальной порядок не сдвинулся). +- [x] 39.4 **Проверка**: `compileDebugKotlin`/`compileDebugUnitTestKotlin`/`:app:compileDebugKotlin`/`:integration:compileDebugKotlin`/`:docs:compileDebugKotlin` — все с `--rerun-tasks` (после массового переименования файлов, чтобы не полагаться на кеш Gradle) — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + +## 40. `getDefaultValue()` → `interactionSource`/Motion — StatefulValue читались неверно + +Пользователь: «заметил, что ты достаешь все параметры из стиля в BubbleBody через getDefaultValue - это неправильно, т.к. они там интерактивные. Посмотри, как выполнены компоненты в uikit-compose. посмотри как используется interactionSource и motion». Перед правкой — исследование (не implementation) через `Explore`-агента: полное сопоставление `StatefulValue.getValue/getValueAsState(interactionSource, ...)` (мгновенный резолв по текущему состоянию из `InteractionSource`, без анимации) и `StatefulValue.get{Brush|Color|TextStyle|...}AsState(motion.context, motion.style.<поле>)` (тот же резолв, но с плавной анимацией перехода через `Motion`/`MotionContext`/`MotionProperty`), на реальных `Button.kt`/`BaseButton.kt`/`Chip.kt`/`Card.kt` из `sdds-core/uikit-compose`. + +- [x] 40.1 **Вывод исследования** — конвенция проекта: обязательный `interactionSource: MutableInteractionSource` создаётся у ТОЙ композиции, что владеет `.clickable`/`.surface`/`.toggleable`, и передаётся ОДНИМ И ТЕМ ЖЕ экземпляром и в этот модификатор, и во все резолвы `StatefulValue` компонента — иначе hover/press не отражаются в резолвленных значениях (клик и стиль наблюдают РАЗНЫЕ, несвязанные источники событий). Внутри одного компонента одни поля (обычно `Brush`/цвет/текстовый стиль — "красочные", где плавный переход визуально важен) заворачиваются в `Motion` (создаётся один раз через `rememberXxxMotion(motionContext = rememberMotionContext(interactionSource))`), другие (дименшены/`Dp` — где визуально не важна плавность, снап нормален) читаются НАПРЯМУЮ через `getValue(interactionSource)`/`getValueAsState(interactionSource)`, без `Motion` вообще — оба пути внутри ОДНОГО компонента одновременно, а не "либо всё через Motion, либо всё напрямую" (см. `BaseButton.kt`: `bgBrush`/`iconBrush`/`labelBrush` — Motion, `minWidthValues`/`heightValues`/`paddingStartValues` — напрямую). +- [x] 40.2 **`BubbleBody`+`BubbleOverlayItem`** (`BubbleHost.kt`, композит, специально названный пользователем) — единственный `interactionSource`, заведённый в `BubbleOverlayItem` (нужен уже там — для `circleSize`, используемого в позиционировании ДО того, как `BubbleBody` вообще скомпонован) и переданный вниз в `BubbleBody`. `Motion` (см. 40.4) — только для `bodyBrush`/`iconBrush` (`getBrushAsState(motion.context, motion.style.bodyColor/iconColor)`); `borderBrush`/`textColor`/`textStyle`/все дименшены — через `getValue`/`getValueAsState(interactionSource)` напрямую, без Motion. Тот же `interactionSource` передаётся в `.clickable(onClick = entry.onBodyClick)`. + - **Бейдж-крестик внутри тела — СВОЙ отдельный `closeInteractionSource`, но ТОЛЬКО для его `.clickable`** (ripple/focus корректно скоуплены на сам крестик, а не на всё тело) — заливка/иконка/обводка бейджа-крестика (передаваемые в `BubbleTriggerIcon`) при этом БЕРУТСЯ из уже резолвленных `bodyBrush`/`iconBrush`/`borderBrush` ТЕЛА, а не резолвятся заново через `closeInteractionSource`. Осознанное отступление от "1 interactionSource = 1 резолв стиля" — тело+шейка+круг+бейдж исторически единая Union-форма БЕЗ видимого шва (см. «Баг №5», `buildBubblePath`); если бы у бейджа был собственный резолв, наведение/нажатие именно на крестик давало бы ДРУГОЙ цвет заливки прямо на стыке с телом — ровно тот шов, которого добивались избежать в более ранних раундах. +- [x] 40.3 **Локальный (свёрнутый) бейдж** (`BubbleTrigger.kt`) — тот же паттерн, СВОЙ отдельный `badgeInteractionSource`+`Motion` (независимая от оверлея интерактивная поверхность — оверлей и локальный бейдж физически никогда не видны одновременно, `interactionSource` оверлея создаётся в другом файле в момент регистрации в хосте, делить нечего). Заведены В НАЧАЛЕ функции (а не внутри `Box` ниже, как было бы естественнее по месту использования) — чтобы `circleSize` тоже резолвился через `interactionSource`, а не `getDefaultValue()`: `circleSize` используется и вне визуального `Box` (в `anchorPosition`/регистрации оверлея), нужен раньше. +- [x] 40.4 **`BubbleStyle.innerGlowLayer()`** (`BubbleInnerGlowPainter.kt`) — новый обязательный параметр `interactionSource: InteractionSource`, функция стала `@Composable` (нужно для `StatefulValue.getValue(interactionSource)` внутри). `shadow` НЕ входит в `BubbleMotionStyle` (тень не анимируется отдельно от заливки, которую обрамляет) — обычный `getValue(interactionSource)`, без Motion. +- [x] 40.5 **Уже существующий, ранее неиспользованный scaffold `BubbleMotionStyle`/`rememberBubbleMotion`** (`motion/bubble/BubbleMotionStyle.kt`/`BubbleMotion.kt`, из раздела переименования №39 — были буквально не задействованы нигде) оказался задуман РОВНО под эту задачу: заранее определяет `bodyColor`/`iconColor: MotionProperty` — именно те 2 поля стиля, что реально используются и в теле, и в обоих бейджах. Просто подключён (`rememberBubbleMotion(motionContext = rememberMotionContext(interactionSource))`), новых motion-полей заводить не понадобилось. +- [x] 40.6 **Осознанно НЕ тронуто** (`BubbleProgress.kt`, `BubbleTriggerTextContent` в `BubbleTrigger.kt`) — оба рендерятся ВНУТРИ непрозрачного content-слота (`entry.content()`), а не под каким-либо `.clickable` компонента; ни один `interactionSource` компонента физически не дотягивается туда без расширения ПУБЛИЧНОГО контракта `content: @Composable () -> Unit` (например, до `@Composable (InteractionSource) -> Unit`) — это отдельное, более инвазивное изменение публичного API, не то, что просили в этом раунде. Оставлены на `getDefaultValue()` с явным комментарием-обоснованием на месте (не молча). +- [x] 40.7 **Проверка**: `compileDebugKotlin` (в т.ч. с `--rerun-tasks`, дважды подтверждено) / `compileDebugUnitTestKotlin` / `:app:compileDebugKotlin` / `:integration:compileDebugKotlin` / `:docs:compileDebugKotlin` — все чисто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + - **Честная оговорка**: `Bubble.Default` (`BubbleStyles.kt`) пока не задаёт НИ ОДНОГО interactive-варианта ни для одного поля (`isStateful()` везде `false`) — значит, поведение СЕГОДНЯ визуально идентично до/после этой правки (у `getValue`/`getValueAsState` есть быстрый путь именно на этот случай: `if (!isStateful()) return getDefaultValue()`). Это архитектурная правка на будущее (когда/если появятся hover/press-варианты в стиле), а не видимый сейчас фикс — визуальной регрессии не ожидается, но и подтвердить эмпирически специально нечего было проверять. + +## 41. Все радиусы и иконки — через стиль + +Пользователь: «давай убедимся, что все радиусы мы задаем через стиль. а так же что все иконки берем из compose пака и тоже задаем их через стиль». + +- [x] 41.1 **Аудит радиусов** (`grep` по всем `.kt` в `components/bubble/`) — единственное нарушение: `BubbleProgress.kt` вычислял радиус "пилюльных" торцов сегментов прогресс-бара геометрически, прямо в `drawWithCache` (`CornerRadius(size.height / 2f)`), а не читал из стиля. Все остальные радиусы (`bodyCornerRadius`/`bodyNearCornerRadius`/`neckFilletRadius` в `BubbleHost.kt`/`BubbleShape.kt`) уже были токенами стиля с 39/40 разделов — `BubbleShape.kt` только клэмпит их внутренне для безопасности геометрии (`maxSafeCorner` и т.п.), сами значения приходят параметрами, не хардкожены. `BubbleStarFourShape.kt` скруглений не использует вовсе (звезда, только прямые/дуговые сегменты). + - **Сверено с прецедентом** — `ProgressBarStyle` (`sdds-core/uikit-compose`) уже решает ровно эту же задачу: `backgroundCornerRadius`/`indicatorCornerRadius: Dp` — явные токены стиля с дефолтом `= height / 2` (не подставляется в код рисования напрямую), тот же приём применён здесь. +- [x] 41.2 **`BubbleDimensions.progressCornerRadius: StatefulValue`** (`BubbleStyle.kt`) — новый токен, дефолт `2.dp` (половина дефолтного `progressHeight = 4.dp` в билдере верхнего уровня; вариация `Bubble.Default` задаёт свой `progressCornerRadius(1.dp)` явно, половина своего `progressHeight(2.dp)` — та же конвенция самодокументируемости, что у остальных токенов вариации). `BubbleProgress.kt` читает его через `.getDefaultValue()` (та же деталь, что и остальные поля этого файла, см. 40.6 — content-слот, `interactionSource` компонента сюда не дотягивается) и клэмпит `.coerceAtMost(size.height / 2f)` да дополнительно `.coerceAtMost(gapLeft/gapRight ширина / 2f)` для каждого сегмента отдельно — та же защита от вылезающего скругления на почти схлопнутом разрывом сегменте, что была и раньше, только источник значения теперь стиль, а не жёстко `size.height / 2f`. +- [x] 41.3 **Аудит иконок** — `BubbleTriggerIcon.kt` рисовал звёздочку/крестик триггера через `resourceImageSource(com.sdds.icons.R.drawable.ic_star_four_fill_16 / ic_close_24)` — старый XML/ресурсный пак иконок (`sdds-icons`, несовместим с `commonMain`/KMP), хардкожен внутри компонента, не настраивался стилем — в отличие от `chevronIcon` (`BubbleTrigger.kt`), который уже был параметром с дефолтом `SddsIcons.DisclosureRightOutline16` из compose-пака (`sdds-icons-compose`). Найдены точные 1:1 эквиваленты в compose-паке: `SddsIcons.StarFourFill16`/`SddsIcons.Close24` (тот же `sdds-core/icons-compose`, сгенерированы из общего с XML-паком набора). +- [x] 41.4 **`BubbleStyle.starIcon`/`closeIcon: ImageVector`** (`BubbleStyle.kt`, top-level поля, НЕ `StatefulValue` — по тому же прецеденту, что и `autoCollapseDelayMillis`: иконка не зависит от состояния интеракции, только от прогресса морфинга триггера, который компонент анимирует отдельно). Builder-методы `starIcon(icon)`/`closeIcon(icon)` без пары `StatefulValue`-оверлоадов (тот же паттерн, что `autoCollapseDelay`). Дефолты в билдере — `SddsIcons.StarFourFill16`/`SddsIcons.Close24`. + - **`BubbleTriggerIcon.kt`** — новые обязательные параметры `starIcon`/`closeIcon: ImageVector` (рядом с `tint`, перед `backgroundBrush` — тот же принцип "что рисуем" рядом друг с другом), `resourceImageSource(com.sdds.icons.R.drawable.*)` → `imageVectorSource(starIcon/closeIcon)`. Компонент по-прежнему не принимает весь `BubbleStyle` целиком (см. KDoc, не изменилось) — только уже резолвленные значения, как и остальные параметры. + - Оба вызывающих места (`BubbleTrigger.kt` — локальный бейдж, `BubbleHost.kt` — `BubbleBody`) передают `style.starIcon`/`style.closeIcon`. + - **`Bubble.Default`** (`BubbleStyles.kt`) — `.starIcon(SddsIcons.StarFourFill16)`/`.closeIcon(SddsIcons.Close24)` явно (та же конвенция самодокументируемости). +- [x] 41.5 **Docs** — `BubbleUsage.md` не тронут: он и раньше не перечислял каждый токен стиля по отдельности (нет упоминаний `bodyCornerRadius`/`neckWidth`/и т.п.), только поведенческий API (`chevronIcon`, колбэки, `autoCollapseDelayMillis`) — новые токены `progressCornerRadius`/`starIcon`/`closeIcon` того же уровня детализации, что уже не документировался отдельно, добавлять не потребовалось. +- [x] 41.6 **Проверка**: `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`compileDebugUnitTestKotlin` — все 5, `--rerun-tasks`, чисто, `BUILD SUCCESSFUL`. `grep` по `components/bubble/` на `resourceImageSource`/`com.sdds.icons.R` — пусто (не считая упоминаний в KDoc-комментариях, объясняющих отказ от старого пака). Тесты/detekt/spotless не запускались (стоящая просьба пользователя). Визуальная проверка на устройстве/эмуляторе не выполнена — иконки те же самые по форме (1:1 эквиваленты из общего source-набора Valkyrie), только другой механизм рендера (`ImageVector` вместо XML-ресурса), видимой регрессии не ожидается. + +## 42. `BubbleBody` — в отдельный файл + +Пользователь: «почему у нас BubbleBody содержится в файле с BubbleHost? давай вынесем его в отдельный файл, как триггер». + +- [x] 42.1 **`BubbleBody.kt`** (новый файл) — `BubbleBody` composable целиком перенесён из `BubbleHost.kt`, вместе с ЕГО частными хелперами, использующимися только внутри него: `bubbleBadgeAlignment` (позиция бейджа-крестика), `bubbleReservedPadding` (зарезервированное место под круг+шейку в content-слоте), `bubbleBouncePivot` (пивот овершут-скейла тела) и константы `BODY_ALPHA_LOW`/`BODY_ALPHA_HIGH`. Симметрично тому, как уже устроен `BubbleTrigger` в собственном `BubbleTrigger.kt`. +- [x] 42.2 **`BubbleHost.kt`** — остался владельцем реестра/хоста и позиционирования оверлея: `BubbleHostState`/`rememberBubbleHostState`/`LocalBubbleHostState`/`BubbleHost`/`BubbleOverlayEntry`/`BubbleOverlayItem`/`bubbleOverlayPlacement`/`DISMISS_MIN_SCALE`. `BubbleOverlayItem` по-прежнему резолвит общий `interactionSource`/`circleSize` (нужны раньше, для позиционирования, см. существующий комментарий) и передаёт их в `BubbleBody(entry, circleSize, interactionSource)` — вызов остался прежним, `BubbleBody` — по-прежнему `internal`, доступен без импорта (тот же пакет `components.bubble`). +- [x] 42.3 **Обновлены устаревшие file-пути в KDoc/комментариях**, указывавшие на `BubbleBody`/тело бабла как на часть `BubbleHost.kt` (стало неверно после переноса): `BubbleStyle.kt` (`textStyle`), `BubbleShape.kt` (`@param size`), `BubbleTrigger.kt` (комментарий про `badgeInteractionSource`), `BubbleTriggerIcon.kt` (2 места — `@param backgroundBrush`, комментарий про собственный glow), `BubbleInnerGlowPainter.kt` (комментарий про альфу), `BubbleStyles.kt` (KDoc `shadow`). `BubbleProgress.kt` — ссылка исправлена на `BubbleBody.kt` вместо `BubbleHost.kt`. +- [x] 42.4 **Проверка**: `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`compileDebugUnitTestKotlin` — все 5, `--rerun-tasks`, чисто, `BUILD SUCCESSFUL`, без предупреждений о неиспользуемых импортах в обоих файлах (импорты честно поделены между `BubbleHost.kt`/`BubbleBody.kt` по фактическому использованию). Чисто механический перенос — публичного/внутреннего API не менялось, поведение не затронуто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + +## 43. Уборка комментариев-нарративов + +Пользователь: «убери комментарии в коде, которые ты оставил». Уточнение через `AskUserQuestion`: убрать конкретно комментарии-нарративы про историю правок (вида «по правке пользователя...», «см. tasks.md», «раньше было X, стало Y», ссылки на баг-репорты) — оставить KDoc публичного API и комментарии, объясняющие неочевидный WHY без ссылок на историю/пользователя. + +- [x] 43.1 **Просмотрены и вычищены все файлы компонента**: `BubbleTrigger.kt`, `BubbleBody.kt`, `BubbleHost.kt`, `BubbleStyle.kt`, `BubbleStyles.kt`, `BubbleProgress.kt`, `BubbleTriggerIcon.kt`, `BubbleInnerGlowPainter.kt`, `BubbleShape.kt`, `BubbleStarFourShape.kt`, `BubbleOvershootEasing.kt`, `PlasmaHomedsComposeCustomComponents.kt` (`BubbleKey`). `BubbleState.kt`, `motion/bubble/BubbleMotionStyle.kt`, `motion/bubble/BubbleMotion.kt`, сэндбокс/тесты/доки — уже были чистыми, правок не потребовалось. +- [x] 43.2 **Подход**: там, где нарратив был ЕДИНСТВЕННЫМ содержанием комментария («по правке пользователя добавили X») — убран целиком или заменён описанием текущего поведения; там, где внутри был ещё и non-obvious технический WHY (например, почему `BlurMaskFilter` не годится при аппаратном ускорении в `BubbleInnerGlowPainter.kt`, или почему перенос в матрице должен быть в строке, а не в столбце, в `BubbleShape.kt`) — WHY сохранён, обёртка «по правке/по репорту пользователя»/«см. tasks.md»/«баг №N» убрана. +- [x] 43.3 **Проверка**: `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`compileDebugUnitTestKotlin` — все 5, `--rerun-tasks`, чисто, `BUILD SUCCESSFUL`. Чисто косметическая правка (только комментарии/KDoc) — ни публичный API, ни поведение не затронуты. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + +## 44. Уборка комментариев — раунд 2, жёстче + +Пользователь: «Убери все комментарии в своем коде, кроме kdoc. если в kdoc есть какая-то история - тоже убирай. kdoc должен содержать краткое описание параметров, функции и т д». Более радикальное требование, чем в разделе 43: там убирались только нарративы «по правке пользователя»/«см. tasks.md», здесь — АБСОЛЮТНО все построчные `//`-комментарии без исключения, плюс KDoc сжат до краткого описания (WHAT), даже там, где раньше был сохранён non-obvious технический WHY (например, разбор трюка с `BlurMaskFilter` при аппаратном ускорении в `BubbleInnerGlowPainter.kt`, или разбор матрицы переноса в `BubbleShape.kt`). + +- [x] 44.1 **Полностью переписаны** (все построчные `//`-комментарии удалены, KDoc сжат до краткого WHAT): `BubbleTrigger.kt`, `BubbleBody.kt`, `BubbleHost.kt`, `BubbleStyle.kt`, `BubbleStyles.kt`, `BubbleProgress.kt`, `BubbleTriggerIcon.kt`, `BubbleInnerGlowPainter.kt`, `BubbleShape.kt`, `BubbleStarFourShape.kt`, `BubbleOvershootEasing.kt`, `BubbleStory.kt` (сэндбокс), `ComposeBubbleScreenshotTest.kt`. `BubbleState.kt`, `motion/bubble/*`, `PlasmaHomedsComposeCustomComponents.kt`, `BubbleSamples.kt` — уже были без построчных комментариев и с достаточно кратким KDoc, правок не потребовалось. +- [x] 44.2 **Публичный API/сигнатуры не менялись** — только текст комментариев/KDoc. `@param`-документация публичных `BubbleTrigger`/`BubbleHost`/`BubbleStyle`/`BubbleDimensions`/`BubbleColors` и т.п. сохранена, но сжата до одной строки на параметр без разбора альтернатив/истории. +- [x] 44.3 **Проверка**: `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`compileDebugUnitTestKotlin` — все 5, `--rerun-tasks`, чисто, `BUILD SUCCESSFUL`. Финальный `grep` по всему дереву компонента на `^\s*//` — пусто. Тесты/detekt/spotless не запускались (стоящая просьба пользователя). + +## 45. Spotless + +Пользователь: «теперь запусти spotless и поправь замечания» — впервые явно попросил запустить spotless (ранее стоял запрет только на тесты, spotless отдельно не запускался). + +- [x] 45.1 **`spotlessCheck`** (`:plasma.homeds.compose`/`:app`/`:docs`/`:integration`) нашёл 2 класса замечаний, оба — прямое следствие правки 44 (массовое ручное редактирование комментариев/импортов без прогона форматтера): + - `BubbleTrigger.kt:185` — превышение `max-line-length` (120) на строке с `val (resolvedPlacement, resolvedAlignment) = if (currentPlacement.value == BubblePlacement.Auto) {` — исправлено вручную (перенос условия `if` на отдельную строку), т.к. `ktlint` эту строку не переносит сам, только валидирует длину. + - Неиспользуемые импорты, оставшиеся после удаления `//`-комментариев с примыкающим кодом (`IntOffset` в `BubbleHost.kt`, `Size` в `BubbleProgress.kt`, `dp` в `BubbleTrigger.kt`), нарушенный порядок импортов (`BubbleStyles.kt`, `ComposeBubbleScreenshotTest.kt`) и форматирование `@file:Suppress` (`BubbleStyles.kt`, был с лишним отступом) — все исправлены автоматически через `spotlessApply` (`:plasma.homeds.compose`/`:app`/`:docs`/`:integration`). +- [x] 45.2 **Проверка**: `spotlessCheck` — все 4 модуля чисто. `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`compileDebugUnitTestKotlin` — все 5, `--rerun-tasks`, чисто, `BUILD SUCCESSFUL`. Тесты/detekt не запускались (не просили). + +## 46. Detekt + +Пользователь: «теперь запусть detekt, там тоже есть замечания». + +- [x] 46.1 **`detekt`** (`:plasma.homeds.compose`/`:app`/`:docs`/`:integration`) нашёл 4 `CyclomaticComplexMethod` (порог 15, не связано с правками 44/45 — комментарии не влияют на цикломатическую сложность, просто это первый прогон detekt за сессию на этом компоненте): + - `bubbleBadgeAlignment` (`BubbleBody.kt:179`, сложность 15) и `bubbleBouncePivot` (`BubbleBody.kt:223`, сложность 15) — вложенные `when` по `BubblePlacement`×`BubbleAlignment` (4×3 варианта размещения). + - `bubbleOverlayPlacement` (`BubbleHost.kt:137`, сложность 15) — та же природа, резолв x/y координат по тем же 4×3 вариантам. + - `DefaultBubbleDimensions.Builder.build()` (`BubbleStyle.kt:610`, сложность 19) — билдер с ~18 полями через elvis-присваивания (`field ?: default`), каждое считается веткой. + - Все 4 — тот же паттерн, что уже подавлен в проекте: `@Suppress("CyclomaticComplexMethod")` уже стоит на публичном `BubbleTrigger` (стейт-машина) и на `buildCanonicalBubblePath` (`BubbleShape.kt`, вместе с `LongParameterList`) — там сложность тоже от исчерпывающих `when` по 4 сторонам/3 выравниваниям, а не от запутанной логики; искусственное дробление на подфункции по одной ветке только ухудшило бы читаемость. По тому же прецеденту добавлен `@Suppress("CyclomaticComplexMethod")` на все 4 функции, без изменения логики. +- [x] 46.2 **Проверка**: `detekt` — все 4 модуля чисто, `BUILD SUCCESSFUL`. `spotlessCheck` + `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`compileDebugUnitTestKotlin` — все 5, `--rerun-tasks`, чисто. Тесты не запускались (не просили). + +## 47. Тесты + +Пользователь: «запусти тесты и поправь если что упадёт». + +- [x] 47.1 **`verifyRoborazziDebug`** — golden-скриншоты для `ComposeBubbleScreenshotTest` никогда не были записаны (компонент собран с нуля в этой сессии) — `screenshots-compose/` не содержал ни одного файла `test*Bubble*`/`testCollapsedShowsStarIcon*`. Все 6 (3 теста × 2 темы) записаны через `recordRoborazziDebug`. +- [x] 47.2 **Найден и исправлен реальный баг теста** (`ComposeBubbleScreenshotTest.kt`) — все 3 теста вручную вызывали `captureScreenRoboImage()` в конце, ДУБЛИРУЯ уже подключённый через `RoborazziConfigCompose` `RoborazziRule` (`captureType = LastImage`, captureRoot = `composeTestRule.onRoot()`). `captureScreenRoboImage()` — не связанная с этим Rule функция, снимающая ВЕСЬ экран/окно (со системным заголовком `ComponentActivity`), а не только Compose-контент; ни один другой скриншот-тест в проекте (проверено на `ComposeBadgeScreenshotTest`) её не вызывает — полагаются целиком на автоматический захват Rule'а в конце теста. Убраны все 3 вызова (и неиспользуемый импорт) — тесты теперь снимают ровно то же самое, что и остальные компоненты проекта. +- [x] 47.3 **Проверка**: `verifyRoborazziDebug --tests "*ComposeBubbleScreenshotTest*"` — чисто, стабильно (перепрогнано дважды). `testDebugUnitTest` для `:plasma.homeds.compose` — весь модуль, 0 failures/errors по всем test-result XML (не только Bubble). `compileDebugKotlin`/`:app:compileDebugKotlin`/`:docs:compileDebugKotlin`/`:integration:compileDebugKotlin`/`spotlessCheck`/`detekt` — чисто. + - **Не в скоупе**: `:docs:compileDebugUnitTestKotlin` падает на `Unresolved reference 'ComposableSample'` в `ComposeDocScreenshotTest.kt` — файл не тронут (`git status` пусто), последний коммит — PR #857 (NumberPanel), никак не связан с Bubble; воспроизводится и без Bubble-правок. Не исправлялось — вне области этой задачи. diff --git a/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/.openspec.yaml b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/.openspec.yaml new file mode 100644 index 0000000000..e8cda9e50e --- /dev/null +++ b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-10 diff --git a/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/design.md b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/design.md new file mode 100644 index 0000000000..0483fd0347 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/design.md @@ -0,0 +1,96 @@ +## Context + +`BubbleTrigger` сейчас сам хранит `BubbleState`, раскрывается по собственному click-handler, запускает пятисекундный таймер и после клика по крестику переходит в `Hidden`. `BubbleHost` при этом только хранит overlay-записи и предоставляет общую координатную систему. Такая модель смешивает визуальный компонент с продуктовой политикой показа и не даёт consumer удобно координировать несколько баблов. + +Исходный change `add-homeds-upsell-bubble` уже заархивирован, поэтому изменение оформляется отдельно. Оно остаётся узко ограниченным `tokens/plasma.homeds.compose`; внешний экран «Мой дом» в репозитории отсутствует. + +## Goals / Non-Goals + +**Goals:** + +- Сделать развёрнутость body полностью controlled-состоянием consumer. +- Оставить компоненту рендеринг, анимацию и overlay-позиционирование. +- Дать каждому `BubbleTrigger` независимый сигнал полной видимости внутри `BubbleHost`. +- Позволить consumer управлять любым числом баблов и самостоятельно выбирать политику конкуренции между ними. +- Удалить поведенческий таймер из стиля и реализации. +- Превратить `BubbleStory` в проверяемый пример ручного управления несколькими триггерами с логированием callback-ов. + +**Non-Goals:** + +- Готовые режимы `Auto`, `Manual` или смешанные стратегии в Bubble API. +- Таймеры автоматического раскрытия или сворачивания в компоненте либо Story. +- Хранение продуктового признака dismissed/viewed внутри дизайн-системы. +- Определение фактической видимости с учётом перекрывающих окон, системных диалогов или других composable. +- Ограничение количества одновременно развёрнутых баблов. + +## Decisions + +### 1. Единственный источник состояния body — параметр `expanded: Boolean` + +Обе публичные перегрузки `BubbleTrigger` получают обязательный параметр `expanded`. Значение `false` задаёт свёрнутый триггер, `true` — развёрнутый body. Внутренние анимационные progress-значения остаются локальными деталями реализации и анимируются к target, соответствующему `expanded`; быстрое изменение target должно корректно прерывать текущую анимацию. + +Компонент не предоставляет публичный императивный controller/state object. Controlled Boolean следует обычному однонаправленному Compose API, легко хранится по стабильному бизнес-идентификатору и позволяет consumer выразить как `expandedId: Id?`, так и набор `expandedIds: Set`. + +*Альтернатива*: публичный `BubbleTriggerState` с `expand()`/`collapse()`. Отклонено: он создаёт второй источник истины рядом с состоянием экрана и усложняет синхронизацию с ViewModel. + +### 2. Callback-и сообщают намерения, но не меняют состояние компонента + +`onTriggerClick` только сообщает о клике по свёрнутому триггеру. Consumer сам устанавливает `expanded = true`, если это соответствует его политике. `onExpandedClick` остаётся уведомлением о клике по body. + +`onDismissed` заменяется на `onDismissRequest`: клик по крестику отправляет запрос consumer, но Bubble не хранит `Hidden` и не решает, сворачивать body либо убирать весь trigger из композиции. Полное скрытие по-прежнему выражается отсутствием `BubbleTrigger` в composition. + +*Альтернатива*: сохранить внутренний `Hidden` после callback-а. Отклонено: внешний `expanded` перестал бы быть единственным источником истины, а повторный показ потребовал бы неявного сброса внутреннего latch. + +### 3. Несколько баблов независимы, арбитраж принадлежит consumer + +`BubbleHost` продолжает поддерживать несколько overlay-записей. У каждого `BubbleTrigger` собственные `expanded` и callback-и. Компонент не сворачивает ранее раскрытый бабл при раскрытии нового. + +Для режима «не более одного» consumer хранит один `expandedId`; для одновременного раскрытия — набор идентификаторов. Это исключает необходимость передавать бизнес-ключи в публичный API Host. + +### 4. Видимость сообщается callback-ом конкретного `BubbleTrigger` + +Добавляется опциональный `onTriggerVisibilityChange: (Boolean) -> Unit`. Callback расположен в API Trigger, поэтому consumer связывает событие со своим идентификатором обычным замыканием. Глобальный callback Host с `(key, visible)` не вводится. + +Видимым считается триггер, чьи bounds полностью входят в bounds текущего `BubbleHost`. Частично обрезанный триггер считается невидимым. Расчёт выполняется в общей координатной системе Host на основе уже используемых `LayoutCoordinates`, без зависимости от `LazyGridState`, поэтому контракт работает с любым layout-контейнером. + +Callback вызывается только при изменении вычисленного Boolean. Когда ранее видимый триггер отсоединяется от layout, удаляется из композиции или теряет Host, consumer получает `false`, чтобы не хранить устаревшее состояние. Несколько триггеров могут одновременно сообщать `true`. + +*Альтернатива*: consumer читает `LazyGridState.layoutInfo.visibleItemsInfo`. Отклонено как единственный публичный путь: решение привязано к Lazy Grid, требует стабильных ключей и дублирует геометрию, которой Host уже располагает. + +### 5. Поведенческие длительности удаляются из стиля + +`BubbleStyle.autoCollapseDelayMillis` и `BubbleStyleBuilder.autoCollapseDelay()` удаляются вместе с binding `.autoCollapseDelay(5_000)`. В реализации удаляется coroutine-delay автоматического сворачивания. Визуальные токены и motion-цвета этим change не меняются. Внутренняя длительность controlled-анимаций раскрытия и сворачивания устанавливается в 600 мс и остаётся деталью реализации, а не продуктовой настройкой стиля. + +### 6. Story демонстрирует consumer-owned state без автоматизации + +`BubbleStory` при создании случайно выбирает несколько карточек, использует их индексы как стабильные на время жизни композиции идентификаторы и хранит их expanded/dismissed-состояние на своей стороне. Настройка `cardIndex` не предоставляется. Story допускает независимое и одновременное раскрытие нескольких баблов, а клик по body сворачивает соответствующий Bubble, чтобы controlled-анимацию можно было проверить вручную. + +Для каждого trigger id через Android logging с единым tag выводятся вызовы `onTriggerClick`, `onExpandedClick`, `onDismissRequest` и `onTriggerVisibilityChange(visible)`. Story не содержит `autoExpand`, auto-collapse или задержек: она демонстрирует только ручную реакцию consumer на события. + +### 7. Документация и тесты переходят на controlled-контракт + +KDoc обеих перегрузок, `BubbleUsage.md` и doc-samples должны явно разделять body-состояние (`expanded`) и существование trigger в composition. Пример нескольких баблов должен показывать привязку callback-а видимости к идентификатору через замыкание. + +Screenshot/state-тесты должны управлять `expanded` с тестовой стороны, а отдельные тесты — проверять отсутствие самостоятельного перехода по клику, независимость нескольких триггеров и edge-события видимости. + +## Risks / Trade-offs + +- **[Risk] Breaking API потребует миграции всех consumer-вызовов.** → Обновить все вызовы в app/docs/integration/test исходниках одного change; в документации дать прямую таблицу замены параметров и поведения. +- **[Risk] Consumer забудет изменить `expanded` в `onTriggerClick`, и визуально клик ничего не сделает.** → Явно описать controlled-контракт в KDoc и показать рабочий пример в Story/doc-sample. +- **[Risk] Callback видимости может часто вызываться во время скролла.** → Хранить последнее вычисленное значение и отправлять событие только при Boolean-переходе. +- **[Risk] Несколько раскрытых overlay могут перекрываться.** → Это сознательная свобода controlled API; Story демонстрирует независимость, а consumer при необходимости использует один `expandedId`. +- **[Risk] Немедленное удаление Trigger consumer-ом после dismiss не гарантирует отдельную exit-анимацию всего компонента.** → Не обещать такую анимацию в контракте; consumer может сначала изменить controlled-состояние, если продукту нужна последовательность сворачивания перед удалением. + +## Migration Plan + +1. Добавить controlled-параметр и visibility callback, переключить внутренние анимации на внешний target. +2. Заменить dismiss-семантику и удалить внутренние `Hidden`/таймерные переходы. +3. Удалить поведенческое свойство из style-контракта и Default binding. +4. Мигрировать все repository call sites, Story, docs и тесты. +5. Проверить публичный API, модульную компиляцию, тесты, detekt и Spotless. + +Откат выполняется возвратом change целиком; миграции данных или внешних зависимостей нет. + +## Open Questions + +Нет. Полная видимость внутри Host, независимость нескольких баблов и отсутствие автоматического поведения согласованы. diff --git a/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/proposal.md b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/proposal.md new file mode 100644 index 0000000000..1443ca6b88 --- /dev/null +++ b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/proposal.md @@ -0,0 +1,34 @@ +## Why + +Текущее состояние и таймер автосворачивания принадлежат `BubbleTrigger`, поэтому consumer не может полноценно управлять раскрытием нескольких баблов и самостоятельно задавать продуктовую политику показа. Компоненту нужен controlled API, оставляющий дизайн-системе только отображение, анимацию, позиционирование и определение видимости триггера внутри `BubbleHost`. + +## What Changes + +- **BREAKING**: `BubbleTrigger` получает обязательный controlled-параметр раскрытия; изменение этого параметра является единственной причиной перехода между свёрнутым и развёрнутым состояниями. +- **BREAKING**: клик по свёрнутому триггеру больше не раскрывает бабл самостоятельно — компонент только вызывает consumer-callback. +- **BREAKING**: клик по крестику больше не переводит компонент во внутреннее состояние `Hidden`; компонент вызывает dismiss-request, а решение свернуть или убрать триггер из композиции принимает consumer. +- `BubbleTrigger` сообщает consumer об изменении видимости собственного триггера относительно границ `BubbleHost`. Каждый экземпляр имеет независимый callback, поэтому бизнес-идентификатор можно связать с событием через замыкание. +- Внутренние таймеры и автоматические переходы удаляются из Bubble. +- Анимации controlled-раскрытия и сворачивания выполняются за 600 мс. +- **BREAKING**: `autoCollapseDelayMillis` и соответствующий builder-метод удаляются из `BubbleStyle`; поведенческие длительности больше не являются стилевыми токенами. +- Обновляются KDoc, документация и примеры интеграции под полностью ручное управление. +- `BubbleStory` становится эталоном consumer-управления несколькими случайно распределёнными баблами: хранит состояние каждого триггера и логирует все публичные callback-события вместе с идентификатором триггера. Настройка `cardIndex` и автоматическое раскрытие в Story не добавляются. + +## Capabilities + +### New Capabilities + +*(нет)* + +### Modified Capabilities + +- `homeds-upsell-bubble-component`: заменить внутреннюю state machine и автосворачивание на controlled-состояние, добавить независимое уведомление о видимости каждого триггера и передать consumer ответственность за dismiss и координацию нескольких баблов. + +## Impact + +- **Модуль**: `tokens/plasma.homeds.compose` — публичный Compose API Bubble, стиль, реализация `BubbleTrigger`/`BubbleHost`, screenshot-тесты, sandbox Story и документация. +- **Публичный API**: breaking-изменение сигнатур и семантики callback-ов `BubbleTrigger`; удаление поведенческого свойства из `BubbleStyle`/`BubbleStyleBuilder`. +- **Токены и кодогенерация**: визуальные токены не меняются; themebuilder/CMP-генератор не затрагивается, поскольку Bubble остаётся вручную реализованным vertical-only компонентом. +- **Документация**: обновляются KDoc, `BubbleUsage.md` и doc-samples; controlled-пример должен показывать хранение состояния на стороне consumer. +- **Зависимости и Gradle conventions**: не меняются. +- **Валидация**: модульная компиляция `plasma.homeds.compose`, app/docs/integration-модулей, screenshot/state-тесты Bubble, detekt и Spotless для затронутых модулей. diff --git a/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/specs/homeds-upsell-bubble-component/spec.md b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/specs/homeds-upsell-bubble-component/spec.md new file mode 100644 index 0000000000..a6ab867fdb --- /dev/null +++ b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/specs/homeds-upsell-bubble-component/spec.md @@ -0,0 +1,162 @@ +## ADDED Requirements + +### Requirement: Controlled-управление раскрытием body +`BubbleTrigger` SHALL принимать controlled-признак `expanded`, а его текущее значение SHALL быть единственным источником целевого состояния body: `false` задаёт свёрнутый триггер, `true` задаёт развёрнутый бабл. + +#### Scenario: Consumer раскрывает бабл +- **WHEN** consumer меняет `expanded` с `false` на `true` и над триггером смонтирован `BubbleHost` +- **THEN** компонент анимированно раскрывает body и показывает переданное содержимое + +#### Scenario: Consumer сворачивает бабл +- **WHEN** consumer меняет `expanded` с `true` на `false` +- **THEN** компонент анимированно сворачивает body до иконки-триггера + +#### Scenario: Быстрая смена целевого состояния +- **WHEN** consumer меняет `expanded` во время незавершённой анимации предыдущего перехода +- **THEN** компонент прерывает предыдущий переход и анимируется от текущего визуального progress к новому target без скачка + +#### Scenario: Длительность controlled-перехода +- **WHEN** consumer меняет `expanded` в любом направлении +- **THEN** анимация раскрытия или сворачивания достигает target за 600 мс + +#### Scenario: Развёрнутое состояние без BubbleHost +- **WHEN** consumer передаёт `expanded = true`, но `BubbleHost` не смонтирован выше по дереву +- **THEN** overlay-body не отображается, а компонент не изменяет переданное состояние и не падает + +### Requirement: Запрос на закрытие управляется consumer +Клик по крестику развёрнутого бабла SHALL вызывать `onDismissRequest`, но SHALL NOT самостоятельно менять controlled-состояние или хранить внутреннее состояние окончательного скрытия. + +#### Scenario: Consumer принимает запрос на закрытие +- **WHEN** пользователь кликает по крестику и consumer в `onDismissRequest` меняет `expanded` на `false` либо удаляет `BubbleTrigger` из композиции +- **THEN** компонент отображает результат нового consumer-состояния + +#### Scenario: Consumer игнорирует запрос на закрытие +- **WHEN** пользователь кликает по крестику, `onDismissRequest` завершается без изменения `expanded` и триггер остаётся в композиции +- **THEN** бабл остаётся развёрнутым + +### Requirement: Уведомление о видимости конкретного триггера +Каждый `BubbleTrigger` SHALL независимо сообщать через `onTriggerVisibilityChange`, полностью ли его bounds находятся внутри bounds текущего `BubbleHost`. + +#### Scenario: Триггер полностью входит в границы Host +- **WHEN** bounds конкретного триггера впервые полностью входят в bounds `BubbleHost` +- **THEN** callback этого триггера вызывается со значением `true` + +#### Scenario: Триггер частично выходит из границ Host +- **WHEN** хотя бы часть ранее полностью видимого триггера выходит за bounds `BubbleHost` +- **THEN** callback этого триггера вызывается со значением `false` + +#### Scenario: Положение меняется без смены видимости +- **WHEN** триггер перемещается при скролле, но результат проверки полной видимости не меняется +- **THEN** callback повторно не вызывается + +#### Scenario: Видимый триггер удаляется +- **WHEN** ранее видимый триггер удаляется из композиции, отсоединяется от layout либо теряет смонтированный `BubbleHost` +- **THEN** callback этого триггера получает `false` + +#### Scenario: Одновременно видны несколько триггеров +- **WHEN** bounds нескольких триггеров полностью находятся внутри одного `BubbleHost` +- **THEN** каждый триггер независимо сообщает `true` через собственный callback + +### Requirement: Независимое управление несколькими баблами +`BubbleHost` SHALL позволять consumer независимо задавать controlled-состояние каждого зарегистрированного `BubbleTrigger` и SHALL NOT автоматически сворачивать другие баблы. + +#### Scenario: Одновременно раскрыты несколько баблов +- **WHEN** consumer передаёт `expanded = true` нескольким триггерам одного Host +- **THEN** Host регистрирует и отображает overlay каждого из них без автоматического изменения состояния остальных + +#### Scenario: Consumer разрешает только один раскрытый бабл +- **WHEN** consumer переводит один trigger в `expanded = true`, одновременно передавая `expanded = false` остальным +- **THEN** компонент отображает только выбранный развёрнутый бабл + +### Requirement: Стиль не содержит политики показа +`BubbleStyle` SHALL содержать только визуальные и motion-свойства компонента и SHALL NOT содержать задержку автоматического сворачивания или другую длительность продуктового показа. + +#### Scenario: Consumer задаёт длительность во внешней логике +- **WHEN** consumer реализует таймер раскрытия или сворачивания +- **THEN** длительность хранится и применяется consumer-ом через изменение `expanded`, без настройки `BubbleStyle` + +### Requirement: Story демонстрирует ручное управление и callback-и +`BubbleStory` SHALL демонстрировать несколько независимо управляемых триггеров и SHALL логировать каждый публичный callback вместе со стабильным идентификатором соответствующего триггера. + +#### Scenario: Callback-и логируются с идентификатором +- **WHEN** в Story вызывается `onTriggerClick`, `onExpandedClick`, `onDismissRequest` или `onTriggerVisibilityChange` +- **THEN** Android log содержит единый tag, идентификатор триггера, имя callback-а и payload события при его наличии + +#### Scenario: Story управляет состоянием вручную +- **WHEN** пользователь взаимодействует с несколькими триггерами в Story +- **THEN** Story самостоятельно обновляет их expanded/dismissed-состояние без autoExpand, auto-collapse и иных таймеров + +#### Scenario: Story демонстрирует сворачивание +- **WHEN** пользователь кликает по body раскрытого Bubble в Story +- **THEN** Story переводит только соответствующий trigger в `expanded = false` и позволяет увидеть анимацию сворачивания + +#### Scenario: Триггеры распределяются без настройки индекса +- **WHEN** Story создаёт демонстрационную сетку +- **THEN** она случайно выбирает несколько карточек с Bubble и сохраняет выбор на время жизни композиции, не предоставляя `cardIndex` в настраиваемом состоянии + +## MODIFIED Requirements + +### Requirement: Уведомление о клике по триггеру +Клик по свёрнутому триггеру SHALL опционально вызывать предоставленный consumer-ом callback уведомления и SHALL NOT самостоятельно изменять controlled-признак `expanded`. + +#### Scenario: Клик по триггеру с callback-ом +- **WHEN** пользователь кликает по свёрнутому триггеру и consumer передал `onTriggerClick` +- **THEN** компонент вызывает callback, а последующее состояние определяется только значением `expanded`, переданным consumer-ом + +#### Scenario: Callback не меняет состояние +- **WHEN** `onTriggerClick` завершается без изменения `expanded` +- **THEN** триггер остаётся свёрнутым + +### Requirement: Показ и скрытие управляются композицией, а не флагом +Наличие всего Bubble SHALL управляться consumer-ом через монтирование либо размонтирование `BubbleTrigger`; controlled-признак `expanded` SHALL управлять только переходом между свёрнутым trigger и развёрнутым body и SHALL NOT заменять бизнес-признак наличия предложения. + +#### Scenario: Компонент не смонтирован +- **WHEN** consumer не размещает триггер в дереве композиции для конкретной карточки +- **THEN** ни свёрнутый триггер, ни развёрнутый бабл для этой карточки не отображаются независимо от ранее сохранённого значения `expanded` + +#### Scenario: Consumer окончательно скрывает предложение +- **WHEN** consumer получает `onDismissRequest` и решает больше не показывать предложение +- **THEN** consumer удаляет соответствующий `BubbleTrigger` из композиции либо меняет собственный бизнес-признак показа + +#### Scenario: Consumer показывает новое предложение +- **WHEN** после скрытия требуется показать новое предложение +- **THEN** consumer снова монтирует `BubbleTrigger` и передаёт ему актуальное controlled-состояние + +### Requirement: Морфинг иконки-триггера между звёздочкой и крестиком +Иконка триггера SHALL анимированно переключаться между изображением звёздочки при `expanded = false` и крестика при `expanded = true`; обе иконки SHALL браться из compose-пака иконок дизайн-системы и SHALL задаваться стилем. + +#### Scenario: Controlled-переход к expanded +- **WHEN** consumer меняет `expanded` с `false` на `true` +- **THEN** иконка триггера анимированно меняется со звёздочки на крестик + +#### Scenario: Controlled-переход к collapsed +- **WHEN** consumer меняет `expanded` с `true` на `false` +- **THEN** иконка триггера анимированно меняется с крестика обратно на звёздочку + +### Requirement: Компонент не хранит бизнес-состояние +Компонент SHALL не содержать собственной логики приоритета статус-бейджей, dismissed/viewed-состояния, таймеров показа или выбора единственного активного бабла; все такие решения SHALL принимать consumer до монтирования компонента либо через изменение `expanded`. + +#### Scenario: Приоритет среди статус-бейджей карточки +- **WHEN** на одной карточке потенциально может отображаться несколько статус-бейджей одновременно с Bubble +- **THEN** выбор, что монтировать, делает consumer; компонент не сравнивает Bubble с другими бейджами + +#### Scenario: Координация нескольких Bubble +- **WHEN** на экране находится несколько `BubbleTrigger` +- **THEN** consumer самостоятельно хранит их expanded/dismissed-состояния и выбирает, допускается ли одновременное раскрытие + +## REMOVED Requirements + +### Requirement: Разворот бабла по клику на триггер +**Reason**: Автоматический внутренний переход по клику противоречит controlled API и не позволяет consumer отклонить событие либо скоординировать несколько баблов. + +**Migration**: Передать `expanded` и обновлять его внутри `onTriggerClick` согласно продуктовой политике. + +### Requirement: Автоматическое сворачивание по таймауту +**Reason**: Таймер показа является продуктовой политикой и удаляется из визуального компонента. + +**Migration**: При необходимости consumer запускает собственный timer/effect и по его завершении меняет `expanded` на `false`. + +### Requirement: Явное закрытие скрывает бабл насовсем +**Reason**: Внутреннее состояние `Hidden` создаёт второй источник истины и не позволяет consumer выбирать результат клика по крестику. + +**Migration**: Обработать `onDismissRequest`: изменить `expanded`, удалить `BubbleTrigger` из композиции и/или сохранить dismissed-состояние на стороне consumer. diff --git a/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/tasks.md b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/tasks.md new file mode 100644 index 0000000000..b2eb12810d --- /dev/null +++ b/openspec/changes/archive/2026-09-10-make-homeds-bubble-controlled/tasks.md @@ -0,0 +1,40 @@ +## 1. Controlled API и состояние (`tokens/plasma.homeds.compose`) + +- [x] 1.1 Добавить обязательный `expanded: Boolean` в обе публичные перегрузки `BubbleTrigger`, обновить KDoc и переключить target анимаций раскрытия/иконки на это значение. +- [x] 1.2 Удалить внутренние `BubbleState`/`Hidden`, coroutine-таймер автосворачивания и самостоятельный переход по клику; обеспечить корректное прерывание незавершённой анимации при быстрой смене `expanded`. +- [x] 1.3 Заменить `onDismissed` на `onDismissRequest` и сделать `onTriggerClick`/`onDismissRequest` чистыми уведомлениями, не изменяющими controlled-состояние. +- [x] 1.4 Проверить и при необходимости адаптировать реестр `BubbleHost`, чтобы несколько триггеров с `expanded = true` отображались независимо и не изменяли состояние друг друга. +- [x] 1.5 Установить длительность controlled-анимаций раскрытия и сворачивания в 600 мс и адаптировать ожидания тестов. + +## 2. Видимость триггера (`tokens/plasma.homeds.compose`) + +- [x] 2.1 Добавить каждой перегрузке `BubbleTrigger` опциональный `onTriggerVisibilityChange: (Boolean) -> Unit` и передать его через делегирующую text/progress-перегрузку. +- [x] 2.2 Реализовать проверку полного попадания bounds конкретного триггера в bounds текущего `BubbleHost` в общей координатной системе, включая обновление при скролле и relayout. +- [x] 2.3 Дедуплицировать события видимости и отправлять `false`, когда ранее видимый триггер отсоединён, удалён из композиции или теряет Host. +- [x] 2.4 Добавить тесты начальной видимости, переходов `false → true → false`, отсутствия повторных событий без смены Boolean и независимых callback-ов нескольких триггеров. + +## 3. Style API (`tokens/plasma.homeds.compose`) + +- [x] 3.1 Удалить `autoCollapseDelayMillis` из `BubbleStyle`, `autoCollapseDelay()` из `BubbleStyleBuilder`, значение из реализации builder-а и `.autoCollapseDelay(5_000)` из Default style binding. +- [x] 3.2 Проверить публичные style-сигнатуры и все ручные style-конструкции на отсутствие оставшихся поведенческих длительностей. + +## 4. Story и repository call sites (`tokens/plasma.homeds.compose`) + +- [x] 4.1 Мигрировать `BubbleStory` на controlled API и разместить несколько триггеров со стабильными идентификаторами карточек и независимым expanded/dismissed-состоянием. +- [x] 4.2 Добавить в `BubbleStory` Android-логи с единым tag и trigger id для `onTriggerClick`, `onExpandedClick`, `onDismissRequest` и `onTriggerVisibilityChange`; не добавлять autoExpand, auto-collapse или таймеры. +- [x] 4.3 Мигрировать screenshot-тесты, integration fixtures, previews и остальные вызовы `BubbleTrigger`/кастомизации `BubbleStyle` на новые сигнатуры. +- [x] 4.4 Убрать `cardIndex` из состояния Story и случайно распределять несколько триггеров по карточкам, сохраняя выбранные индексы на время жизни композиции. +- [x] 4.5 Сворачивать соответствующий Bubble по клику на его body в Story, сохраняя controlled-семантику компонента и логирование callback-а. + +## 5. Документация + +- [x] 5.1 Обновить KDoc `BubbleHost`, `BubbleTrigger` и связанного публичного API: описать controlled-состояние, чистые callback-и, семантику полной видимости и независимость нескольких баблов. +- [x] 5.2 Обновить `BubbleUsage.md` и doc-samples: показать ручное хранение `expanded`, обработку dismiss и привязку visibility callback к бизнес-идентификатору через замыкание. +- [x] 5.3 Удалить из документации утверждения об автоматическом сворачивании и настройке длительности через стиль. +- [x] 5.4 Добавить примеры внешней координации нескольких баблов и автоматического раскрытия/сворачивания без возвращения продуктовых таймеров в компонент. + +## 6. Проверка + +- [x] 6.1 Запустить релевантные Bubble unit/screenshot-тесты и обновить эталонные изображения только при ожидаемом визуальном изменении. +- [x] 6.2 Из корня репозитория проверить компиляцию main/test, app, docs и integration для `plasma.homeds.compose` с `--rerun-tasks`. +- [x] 6.3 Запустить module-level detekt и Spotless для затронутых модулей и убедиться, что рабочее дерево не содержит непредусмотренных сгенерированных изменений. diff --git a/openspec/specs/homeds-upsell-bubble-component/spec.md b/openspec/specs/homeds-upsell-bubble-component/spec.md new file mode 100644 index 0000000000..16dafeafb9 --- /dev/null +++ b/openspec/specs/homeds-upsell-bubble-component/spec.md @@ -0,0 +1,211 @@ +# homeds-upsell-bubble-component Specification + +## Purpose +TBD - created by syncing change add-homeds-upsell-bubble. Update Purpose after archive. +## Requirements +### Requirement: Уведомление о клике по триггеру +Клик по свёрнутому триггеру SHALL опционально вызывать предоставленный consumer-ом callback уведомления и SHALL NOT самостоятельно изменять controlled-признак `expanded`. + +#### Scenario: Клик по триггеру с callback-ом +- **WHEN** пользователь кликает по свёрнутому триггеру и consumer передал `onTriggerClick` +- **THEN** компонент вызывает callback, а последующее состояние определяется только значением `expanded`, переданным consumer-ом + +#### Scenario: Callback не меняет состояние +- **WHEN** `onTriggerClick` завершается без изменения `expanded` +- **THEN** триггер остаётся свёрнутым + +### Requirement: Показ и скрытие управляются композицией, а не флагом +Наличие всего Bubble SHALL управляться consumer-ом через монтирование либо размонтирование `BubbleTrigger`; controlled-признак `expanded` SHALL управлять только переходом между свёрнутым trigger и развёрнутым body и SHALL NOT заменять бизнес-признак наличия предложения. + +#### Scenario: Компонент не смонтирован +- **WHEN** consumer не размещает триггер в дереве композиции для конкретной карточки +- **THEN** ни свёрнутый триггер, ни развёрнутый бабл для этой карточки не отображаются независимо от ранее сохранённого значения `expanded` + +#### Scenario: Consumer окончательно скрывает предложение +- **WHEN** consumer получает `onDismissRequest` и решает больше не показывать предложение +- **THEN** consumer удаляет соответствующий `BubbleTrigger` из композиции либо меняет собственный бизнес-признак показа + +#### Scenario: Consumer показывает новое предложение +- **WHEN** после скрытия требуется показать новое предложение +- **THEN** consumer снова монтирует `BubbleTrigger` и передаёт ему актуальное controlled-состояние + +### Requirement: Переход на экран апсейла по клику на развёрнутый бабл +Клик по телу развёрнутого бабла (вне зоны крестика) SHALL вызывать предоставленный вызывающей стороной колбэк перехода на экран апсейла; сам компонент SHALL не выполнять навигацию самостоятельно. + +#### Scenario: Клик по телу развёрнутого бабла +- **WHEN** пользователь кликает по развёрнутому баблу вне области иконки-крестика +- **THEN** компонент вызывает переданный колбэк клика по баблу и не меняет своё состояние показа самостоятельно + +### Requirement: Морфинг иконки-триггера между звёздочкой и крестиком +Иконка триггера SHALL анимированно переключаться между изображением звёздочки при `expanded = false` и крестика при `expanded = true`; обе иконки SHALL браться из compose-пака иконок дизайн-системы и SHALL задаваться стилем. + +#### Scenario: Controlled-переход к expanded +- **WHEN** consumer меняет `expanded` с `false` на `true` +- **THEN** иконка триггера анимированно меняется со звёздочки на крестик + +#### Scenario: Controlled-переход к collapsed +- **WHEN** consumer меняет `expanded` с `true` на `false` +- **THEN** иконка триггера анимированно меняется с крестика обратно на звёздочку + +### Requirement: Бабл отслеживает позицию триггера без системного Popup +Развёрнутый бабл SHALL визуально позиционироваться относительно текущего положения своего триггера и SHALL оставаться корректно позиционированным при скролле или изменении раскладки сетки устройств, без использования системного `Popup`/отдельного окна. + +#### Scenario: Скролл сетки при развёрнутом бабле +- **WHEN** пользователь скроллит сетку устройств, пока бабл находится в состоянии Expanded +- **THEN** развёрнутый бабл перемещается синхронно с триггером, оставаясь визуально привязанным к его текущей позиции + +#### Scenario: Бабл выходит за пределы клипа своей карточки +- **WHEN** бабл развёрнут на карточке, ширина которой меньше ширины развёрнутого бабла +- **THEN** бабл визуально отображается поверх соседних карточек сетки, не обрезаясь границей своей собственной карточки + +### Requirement: Размещение и выравнивание тела относительно триггера +Компонент SHALL позволять вызывающей стороне явно задавать, с какой стороны от триггера появляется тело бабла, и его выравнивание вдоль перпендикулярной оси; компонент SHALL также поддерживать автоматический выбор стороны и выравнивания по свободному месту в границах хоста. + +#### Scenario: Явное указание стороны и выравнивания +- **WHEN** вызывающая сторона передаёт конкретную сторону (сверху/снизу/с начала/с конца) и выравнивание +- **THEN** тело бабла растёт именно в указанную сторону и выравнивается вдоль перпендикулярной оси согласно переданному значению, зеркалируясь под RTL там, где это применимо + +#### Scenario: Автовыбор стороны и выравнивания +- **WHEN** вызывающая сторона запрашивает автоматический выбор стороны +- **THEN** в момент разворота компонент измеряет свободное место от круга-якоря до границ хоста по всем сторонам, выбирает сторону с максимумом свободного места и подбирает выравнивание вдоль перпендикулярной оси по той же логике + +### Requirement: Компонент не хранит бизнес-состояние +Компонент SHALL не содержать собственной логики приоритета статус-бейджей, dismissed/viewed-состояния, таймеров показа или выбора единственного активного бабла; все такие решения SHALL принимать consumer до монтирования компонента либо через изменение `expanded`. + +#### Scenario: Приоритет среди статус-бейджей карточки +- **WHEN** на одной карточке потенциально может отображаться несколько статус-бейджей одновременно с Bubble +- **THEN** выбор, что монтировать, делает consumer; компонент не сравнивает Bubble с другими бейджами + +#### Scenario: Координация нескольких Bubble +- **WHEN** на экране находится несколько `BubbleTrigger` +- **THEN** consumer самостоятельно хранит их expanded/dismissed-состояния и выбирает, допускается ли одновременное раскрытие + +### Requirement: Пользовательский слот содержимого развёрнутого бабла +Развёрнутый бабл SHALL принимать произвольное содержимое от вызывающей стороны как composable-слот и SHALL не накладывать ограничений на его структуру. Компонент SHALL предоставлять этому слоту цвет текста и типографику из текущего стиля неявно, через локальные провайдеры. + +#### Scenario: Слот получает цвет текста и типографику из стиля +- **WHEN** вызывающая сторона размещает текстовый элемент внутри слота содержимого бабла, не указывая цвет или типографику явно +- **THEN** текст отображается цветом и типографикой, заданными в `BubbleStyle` для текущей вариации + +#### Scenario: Произвольное содержимое слота +- **WHEN** вызывающая сторона размещает в слоте содержимое, отличное от простого текста (например, индикатор прогресса) +- **THEN** компонент отображает это содержимое как есть, не изменяя и не проверяя его структуру + +### Requirement: Перегрузка с текстом, шевроном и прогресс-баром +Компонент SHALL предоставлять готовую перегрузку для основного сценария использования — текст с иконкой-шевроном в конце строки и опциональным прогресс-баром под текстом — без необходимости вручную собирать это содержимое через content-слот. + +#### Scenario: Использование перегрузки с текстом и прогрессом +- **WHEN** вызывающая сторона использует перегрузку с параметрами `text` и `progress` +- **THEN** компонент сам собирает текст со встроенной иконкой-шевроном (привязанной к последнему слову последней строки текста) и прогресс-бар под ним, отображающий переданное значение прогресса + +#### Scenario: Прогресс-бар скрыт +- **WHEN** вызывающая сторона указывает, что прогресс-бар показывать не нужно +- **THEN** компонент полностью убирает прогресс-бар вместе с отступом под него, а не только визуально скрывает его с сохранением места + +### Requirement: Радиусы скругления и иконки задаются стилем +Все радиусы скругления, используемые в отрисовке компонента (тело, ближний к триггеру угол, галтель шейки, торцы сегментов прогресс-бара), и обе иконки триггера (звёздочка, крестик) SHALL быть настраиваемыми токенами стиля, а не константами, зашитыми в код отрисовки. + +#### Scenario: Кастомный стиль с иными радиусами и иконками +- **WHEN** вызывающая сторона передаёт стиль с явно указанными значениями радиусов скругления и/или иными иконками звёздочки/крестика +- **THEN** компонент отрисовывает тело, шейку, прогресс-бар и иконки триггера согласно переданным значениям, а не согласно значениям по умолчанию + +### Requirement: Единственная стилевая вариация Default +Публичный стилевой контракт компонента SHALL предоставлять ровно одну готовую вариацию — `Default`; набор размерных/видовых вариаций SHALL быть добавлен отдельным изменением при появлении соответствующей дизайн-спеки. + +#### Scenario: Использование компонента без явного указания стиля +- **WHEN** вызывающий код использует `BubbleTrigger`/`BubbleHost` без явного указания стиля +- **THEN** применяется единственная доступная вариация `Default` + +### Requirement: Controlled-управление раскрытием body +`BubbleTrigger` SHALL принимать controlled-признак `expanded`, а его текущее значение SHALL быть единственным источником целевого состояния body: `false` задаёт свёрнутый триггер, `true` задаёт развёрнутый бабл. + +#### Scenario: Consumer раскрывает бабл +- **WHEN** consumer меняет `expanded` с `false` на `true` и над триггером смонтирован `BubbleHost` +- **THEN** компонент анимированно раскрывает body и показывает переданное содержимое + +#### Scenario: Consumer сворачивает бабл +- **WHEN** consumer меняет `expanded` с `true` на `false` +- **THEN** компонент анимированно сворачивает body до иконки-триггера + +#### Scenario: Быстрая смена целевого состояния +- **WHEN** consumer меняет `expanded` во время незавершённой анимации предыдущего перехода +- **THEN** компонент прерывает предыдущий переход и анимируется от текущего визуального progress к новому target без скачка + +#### Scenario: Длительность controlled-перехода +- **WHEN** consumer меняет `expanded` в любом направлении +- **THEN** анимация раскрытия или сворачивания достигает target за 600 мс + +#### Scenario: Развёрнутое состояние без BubbleHost +- **WHEN** consumer передаёт `expanded = true`, но `BubbleHost` не смонтирован выше по дереву +- **THEN** overlay-body не отображается, а компонент не изменяет переданное состояние и не падает + +### Requirement: Запрос на закрытие управляется consumer +Клик по крестику развёрнутого бабла SHALL вызывать `onDismissRequest`, но SHALL NOT самостоятельно менять controlled-состояние или хранить внутреннее состояние окончательного скрытия. + +#### Scenario: Consumer принимает запрос на закрытие +- **WHEN** пользователь кликает по крестику и consumer в `onDismissRequest` меняет `expanded` на `false` либо удаляет `BubbleTrigger` из композиции +- **THEN** компонент отображает результат нового consumer-состояния + +#### Scenario: Consumer игнорирует запрос на закрытие +- **WHEN** пользователь кликает по крестику, `onDismissRequest` завершается без изменения `expanded` и триггер остаётся в композиции +- **THEN** бабл остаётся развёрнутым + +### Requirement: Уведомление о видимости конкретного триггера +Каждый `BubbleTrigger` SHALL независимо сообщать через `onTriggerVisibilityChange`, полностью ли его bounds находятся внутри bounds текущего `BubbleHost`. + +#### Scenario: Триггер полностью входит в границы Host +- **WHEN** bounds конкретного триггера впервые полностью входят в bounds `BubbleHost` +- **THEN** callback этого триггера вызывается со значением `true` + +#### Scenario: Триггер частично выходит из границ Host +- **WHEN** хотя бы часть ранее полностью видимого триггера выходит за bounds `BubbleHost` +- **THEN** callback этого триггера вызывается со значением `false` + +#### Scenario: Положение меняется без смены видимости +- **WHEN** триггер перемещается при скролле, но результат проверки полной видимости не меняется +- **THEN** callback повторно не вызывается + +#### Scenario: Видимый триггер удаляется +- **WHEN** ранее видимый триггер удаляется из композиции, отсоединяется от layout либо теряет смонтированный `BubbleHost` +- **THEN** callback этого триггера получает `false` + +#### Scenario: Одновременно видны несколько триггеров +- **WHEN** bounds нескольких триггеров полностью находятся внутри одного `BubbleHost` +- **THEN** каждый триггер независимо сообщает `true` через собственный callback + +### Requirement: Независимое управление несколькими баблами +`BubbleHost` SHALL позволять consumer независимо задавать controlled-состояние каждого зарегистрированного `BubbleTrigger` и SHALL NOT автоматически сворачивать другие баблы. + +#### Scenario: Одновременно раскрыты несколько баблов +- **WHEN** consumer передаёт `expanded = true` нескольким триггерам одного Host +- **THEN** Host регистрирует и отображает overlay каждого из них без автоматического изменения состояния остальных + +#### Scenario: Consumer разрешает только один раскрытый бабл +- **WHEN** consumer переводит один trigger в `expanded = true`, одновременно передавая `expanded = false` остальным +- **THEN** компонент отображает только выбранный развёрнутый бабл + +### Requirement: Стиль не содержит политики показа +`BubbleStyle` SHALL содержать только визуальные и motion-свойства компонента и SHALL NOT содержать задержку автоматического сворачивания или другую длительность продуктового показа. + +#### Scenario: Consumer задаёт длительность во внешней логике +- **WHEN** consumer реализует таймер раскрытия или сворачивания +- **THEN** длительность хранится и применяется consumer-ом через изменение `expanded`, без настройки `BubbleStyle` + +### Requirement: Story демонстрирует ручное управление и callback-и +`BubbleStory` SHALL демонстрировать несколько независимо управляемых триггеров и SHALL логировать каждый публичный callback вместе со стабильным идентификатором соответствующего триггера. + +#### Scenario: Callback-и логируются с идентификатором +- **WHEN** в Story вызывается `onTriggerClick`, `onExpandedClick`, `onDismissRequest` или `onTriggerVisibilityChange` +- **THEN** Android log содержит единый tag, идентификатор триггера, имя callback-а и payload события при его наличии + +#### Scenario: Story управляет состоянием вручную +- **WHEN** пользователь взаимодействует с несколькими триггерами в Story +- **THEN** Story самостоятельно обновляет их expanded/dismissed-состояние без autoExpand, auto-collapse и иных таймеров + +#### Scenario: Story демонстрирует сворачивание +- **WHEN** пользователь кликает по body раскрытого Bubble в Story +- **THEN** Story переводит только соответствующий trigger в `expanded = false` и позволяет увидеть анимацию сворачивания + +#### Scenario: Триггеры распределяются без настройки индекса +- **WHEN** Story создаёт демонстрационную сетку +- **THEN** она случайно выбирает несколько карточек с Bubble и сохраняет выбор на время жизни композиции, не предоставляя `cardIndex` в настраиваемом состоянии diff --git a/tokens/plasma.homeds.compose/app/src/main/kotlin/com/sdds/plasma/homeds/sandbox/strories/bubble/BubbleStory.kt b/tokens/plasma.homeds.compose/app/src/main/kotlin/com/sdds/plasma/homeds/sandbox/strories/bubble/BubbleStory.kt new file mode 100644 index 0000000000..fa14e3de9e --- /dev/null +++ b/tokens/plasma.homeds.compose/app/src/main/kotlin/com/sdds/plasma/homeds/sandbox/strories/bubble/BubbleStory.kt @@ -0,0 +1,158 @@ +package com.sdds.plasma.homeds.sandbox.strories.bubble + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sdds.compose.sandbox.ComposeBaseStory +import com.sdds.compose.uikit.Text +import com.sdds.plasma.homeds.components.bubble.BubbleAlignment +import com.sdds.plasma.homeds.components.bubble.BubbleHost +import com.sdds.plasma.homeds.components.bubble.BubblePlacement +import com.sdds.plasma.homeds.components.bubble.BubbleStyle +import com.sdds.plasma.homeds.components.bubble.BubbleTrigger +import com.sdds.plasma.homeds.compose.sandbox.stories.HomeDsBubbleUiStatePropertiesProducer +import com.sdds.plasma.homeds.compose.sandbox.stories.HomeDsBubbleUiStateTransformer +import com.sdds.plasma.homeds.integration.custom.BubbleKey +import com.sdds.sandbox.ComponentKey +import com.sdds.sandbox.Story +import com.sdds.sandbox.StoryUiState +import com.sdds.sandbox.UiState + +/** + * Состояние компонента Bubble в песочнице. + * + * @param hasOffer монтировать ли демонстрационные [BubbleTrigger] + * @param placement с какой стороны от триггера появляется тело, см. [BubblePlacement] + * @param alignment выравнивание тела вдоль оси, перпендикулярной [placement], см. [BubbleAlignment] + * @param progress значение прогресс-бара перегрузки [BubbleTrigger] с `text`/`progress` + * @param hasProgress показывать ли прогресс-бар + */ +@StoryUiState +data class HomeDsBubbleUiState( + override val variant: String = "", + override val appearance: String = "", + val hasOffer: Boolean = true, + val placement: BubblePlacement = BubblePlacement.Top, + val alignment: BubbleAlignment = BubbleAlignment.End, + val progress: Float = 0.5f, + val hasProgress: Boolean = true, +) : UiState { + override fun updateVariant(appearance: String, variant: String): UiState { + return copy(appearance = appearance, variant = variant) + } +} + +@Story +internal object BubbleStory : ComposeBaseStory( + BubbleKey, + HomeDsBubbleUiState(), + HomeDsBubbleUiStatePropertiesProducer, + HomeDsBubbleUiStateTransformer, +) { + @Composable + override fun BoxScope.Content( + style: BubbleStyle, + state: HomeDsBubbleUiState, + ) { + val bubbleCardIndices = remember { + (0 until DEVICE_CARD_COUNT) + .shuffled() + .take(BUBBLE_COUNT) + .toSet() + } + val expandedById = remember(bubbleCardIndices, state.hasOffer) { mutableStateMapOf() } + val dismissedById = remember(bubbleCardIndices, state.hasOffer) { mutableStateMapOf() } + BubbleHost(modifier = Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(GRID_COLUMNS), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(DEVICE_CARD_COUNT) { index -> + Box( + modifier = Modifier + .aspectRatio(1f) + .background(Color.DarkGray, RoundedCornerShape(16.dp)), + ) { + Text( + text = "$index", + textColor = Color.White, + style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), + modifier = Modifier.align(Alignment.Center), + ) + if (index in bubbleCardIndices && state.hasOffer && dismissedById[index] != true) { + BubbleTrigger( + onExpandedClick = { + Log.d(LOG_TAG, "trigger=$index onExpandedClick") + expandedById[index] = false + }, + expanded = expandedById[index] == true, + text = "Прокачать до 100%", + progress = state.progress, + hasProgress = state.hasProgress, + placement = state.placement, + alignment = state.alignment, + style = style, + onDismissRequest = { + Log.d(LOG_TAG, "trigger=$index onDismissRequest") + expandedById.remove(index) + dismissedById[index] = true + }, + onTriggerClick = { + Log.d(LOG_TAG, "trigger=$index onTriggerClick") + expandedById[index] = true + }, + onTriggerVisibilityChange = { visible -> + Log.d( + LOG_TAG, + "trigger=$index onTriggerVisibilityChange: visible=$visible", + ) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp), + ) + } + } + } + } + } + } + + @Composable + override fun Preview(style: BubbleStyle, key: ComponentKey) { + BubbleTrigger( + onExpandedClick = {}, + expanded = false, + style = style, + ) { + Text(text = "Прокачать до 100%") + } + } +} + +private const val GRID_COLUMNS = 3 +private const val DEVICE_CARD_COUNT = 30 +private const val BUBBLE_COUNT = 3 +private const val LOG_TAG = "BubbleStory" diff --git a/tokens/plasma.homeds.compose/docs/build.gradle.kts b/tokens/plasma.homeds.compose/docs/build.gradle.kts index 16fde41440..67b4fbd691 100644 --- a/tokens/plasma.homeds.compose/docs/build.gradle.kts +++ b/tokens/plasma.homeds.compose/docs/build.gradle.kts @@ -18,6 +18,7 @@ android { docusaurus { additionalComponentNames.add("NumberPanel") + additionalComponentNames.add("Bubble") } ksp { diff --git a/tokens/plasma.homeds.compose/docs/override-docs/docs/components/BubbleUsage.md b/tokens/plasma.homeds.compose/docs/override-docs/docs/components/BubbleUsage.md new file mode 100644 index 0000000000..5535963371 --- /dev/null +++ b/tokens/plasma.homeds.compose/docs/override-docs/docs/components/BubbleUsage.md @@ -0,0 +1,266 @@ +--- +title: Bubble +--- + +```kotlin +// @sample: com/sdds/plasma/homeds/docs/samples/Bubble_Default.kt +``` + + + +Bubble — управляемый анимированный бабл апсейла на карточке устройства. Вызывающая сторона задаёт +через `expanded`, отображается ли свёрнутая иконка-триггер или развёрнутый body с произвольным +содержимым. Компонент специфичен для вертикали SberHome и живёт в +`tokens/plasma.homeds.compose`, а не в `sdds-core/uikit-compose` (по аналогии с `NumberPanel`). + +Компонент не содержит бизнес-логики: приоритет между статус-бейджами устройства и баблом апсейла, +признак «есть новое предложение» и переход на экран апсейла — ответственность вызывающей стороны. + +## Двухчастный API — Trigger и Host + +`BubbleTrigger` кладётся внутрь карточки устройства как обычный дочерний composable. +`BubbleHost` монтируется один раз вокруг всей сетки устройств (не на карточку) — он рендерит +развёрнутый бабл поверх соседних карточек, чтобы тот не обрезался клипом отдельной карточки: + +```kotlin +BubbleHost { + // сетка устройств; BubbleTrigger — внутри одной из карточек +} +``` + +Без `BubbleHost` выше по дереву `BubbleTrigger` продолжает работать как обычная свёрнутая +иконка, но разворот не будет виден. + +## Использование с Card + +В карточке устройства `BubbleTrigger` следует размещать в слоте `extra`. Слот предоставляет +`BoxScope`, поэтому положение триггера внутри контентной области карточки задаётся стандартными +модификаторами `align`, `padding` и `offset`. В одном `extra` при необходимости можно разместить +несколько элементов. + +```kotlin +// @sample: com/sdds/plasma/homeds/docs/samples/Bubble_Card.kt +``` + + + +## Controlled-состояние и скрытие + +Наличие всего Bubble управляется композицией: показывать предложение — значит смонтировать +`BubbleTrigger`, скрывать — не монтировать. Параметр `expanded` управляет только body: +`false` оставляет свёрнутый триггер, `true` раскрывает body. Компонент не меняет это значение +самостоятельно и не хранит dismissed-состояние. Controlled-переход в каждую сторону занимает +600 мс: + +```kotlin +var expanded by remember { mutableStateOf(false) } +var hasOffer by remember { mutableStateOf(true) } + +if (hasOffer) { + BubbleTrigger( + onExpandedClick = { /* переход на экран апсейла */ }, + expanded = expanded, + onTriggerClick = { expanded = true }, + onDismissRequest = { + expanded = false + hasOffer = false + /* сохранить «просмотрено» */ + }, + ) { + Text(text = "Прокачать до 100%") + } +} +``` + +## Обработка нажатий + +Три независимые области, каждая — свой колбэк: + +- `onExpandedClick` — клик по ТЕЛУ развёрнутого бабла целиком (текст+прогресс или произвольный + content-слот); компонент сам не переходит на экран апсейла — это делает вызывающая сторона внутри + колбэка. +- `onDismissRequest` — запрос на закрытие по клику на крестик. Компонент не меняет `expanded` и + не удаляет себя; результат определяет вызывающая сторона. +- `onTriggerClick` — клик по самому триггеру (свёрнутая иконка на карточке в состоянии покоя); + callback только уведомляет вызывающую сторону, которая при необходимости меняет `expanded`: + +```kotlin +var expanded by remember { mutableStateOf(false) } + +BubbleTrigger( + onExpandedClick = { /* переход на экран апсейла */ }, + expanded = expanded, + onTriggerClick = { + expanded = true + /* аналитика: триггер открыт */ + }, +) { + Text(text = "Прокачать до 100%") +} +``` + +## Видимость и несколько триггеров + +Каждый `BubbleTrigger` независимо сообщает через `onTriggerVisibilityChange`, полностью ли его +границы находятся внутри `BubbleHost`. Частично вышедший за границы триггер считается невидимым; +callback вызывается только при изменении результата. Идентификатор связывается с событием через +замыкание: + +```kotlin +val expandedById = remember { mutableStateMapOf() } +val visibleById = remember { mutableStateMapOf() } + +devices.forEach { device -> + BubbleTrigger( + onExpandedClick = { /* открыть предложение device.id */ }, + expanded = expandedById[device.id] == true, + onTriggerClick = { expandedById[device.id] = true }, + onDismissRequest = { expandedById.remove(device.id) }, + onTriggerVisibilityChange = { visible -> + visibleById[device.id] = visible + }, + ) { + Text(text = device.offerText) + } +} +``` + +Компонент допускает несколько одновременно раскрытых баблов. В примере выше каждый элемент имеет +собственное состояние в `expandedById`. Если одновременно должен быть раскрыт только один Bubble, +достаточно хранить его идентификатор: + +```kotlin +var expandedId by remember { mutableStateOf(null) } + +devices.forEach { device -> + BubbleTrigger( + onExpandedClick = { /* открыть предложение device.id */ }, + expanded = expandedId == device.id, + onTriggerClick = { expandedId = device.id }, + onDismissRequest = { + if (expandedId == device.id) expandedId = null + }, + ) { + Text(text = device.offerText) + } +} +``` + +## Автоматическое раскрытие и сворачивание + +Автоматическая политика реализуется снаружи компонента. Например, Bubble можно раскрыть, когда +его триггер полностью появился в `BubbleHost`, и свернуть через пять секунд. Для каждого элемента +такой state следует хранить под стабильным бизнес-идентификатором (например, внутри +`key(device.id)` в списке): + +```kotlin +var triggerVisible by remember(device.id) { mutableStateOf(false) } +var expanded by remember(device.id) { mutableStateOf(false) } + +LaunchedEffect(triggerVisible) { + expanded = triggerVisible +} + +LaunchedEffect(expanded) { + if (expanded) { + delay(5_000) + expanded = false + } +} + +BubbleTrigger( + onExpandedClick = { /* открыть предложение device.id */ }, + expanded = expanded, + onTriggerClick = { expanded = true }, + onDismissRequest = { expanded = false }, + onTriggerVisibilityChange = { visible -> + triggerVisible = visible + }, +) { + Text(text = device.offerText) +} +``` + +При выходе триггера за границы Host Bubble сворачивается, а таймер отменяется из-за изменения +`expanded`. Повторный вход запускает сценарий заново. Отдельный effect по `expanded` означает, что +ручное раскрытие через `onTriggerClick` тоже будет ограничено тем же таймаутом. Чтобы показывать +предложение только один раз, consumer может дополнительно хранить `shownIds` и не выставлять +`expanded = true` для уже показанного id. Задержка `5_000` является частью продуктовой логики +consumer-а и не настраивается через `BubbleStyle`. + +## Содержимое бабла + +Основной сценарий — перегрузка `BubbleTrigger(text, progress, ...)`: текст, иконка-шеврон +(`chevronIcon`, drawable-ресурс, по умолчанию `R.drawable.ic_disclosure_right_outline_16`) и +прогресс-бар под текстом +(`progress`, `0f..1f`) — компонент сам собирает содержимое. Шеврон встроен в сам текст как +inline-контент — привязан к последнему слову последней строки и переносится вместе с ней при +переносе текста, а не сидит отдельным элементом сбоку от всего блока текста; центрирован по +высоте строки (`lineHeight`) и заезжает на последний символ текста на `chevronTextOverlap` +(токен стиля, по умолчанию `3.dp`); размер иконки задаётся токеном `chevronIconSize` (по +умолчанию `16.dp`): + +```kotlin +BubbleTrigger( + onExpandedClick = { /* переход на экран апсейла */ }, + expanded = expanded, + text = "Прокачать до 100%", + progress = 0.6f, +) +``` + +Прогресс-бар нарисован как два прямоугольника с разрывом (указателем значения) между ними — +позиция разрыва и есть значение `progress` (`0f` — у самого начала, `1f` — у самого конца), а не +сплошная заливка поверх сплошного трека. Скруглены только крайние (внешние) торцы каждого +прямоугольника — торцы у самого разрыва прямые. Толщина разрыва задаётся токеном стиля +`progressPointerThickness`. `hasProgress = false` полностью убирает прогресс-бар и отступ под +него — для случаев, где прогресс не нужен, а не только скрывает с сохранением места. + +Ширина тела для этой перегрузки зафиксирована токеном стиля `width` (по умолчанию +`100.dp`) — без него `Text`/прогресс-бар растягивали бы тело на всю доступную ширину экрана. + +Обе перегрузки предоставляют цвет текста (`textColor`) и типографику (`textStyle`) из стиля +дочернему `Text` — как своему собственному, так и любому `Text` внутри `content`-слота, через +`LocalTextBrushProducer`/`LocalTextStyle`, а не явным параметром. Для произвольного содержимого +есть отдельная перегрузка с `content`-слотом — компонент не разбирает и не типизирует его: + +```kotlin +BubbleTrigger( + onExpandedClick = { /* переход на экран апсейла */ }, + expanded = expanded, +) { + // произвольный composable-контент +} +``` + +## Размещение и выравнивание + +С какой стороны от триггера появляется тело бабла и как оно выровнено, задаётся двумя параметрами — +терминология popover/tooltip: + +- `placement` (`BubblePlacement.Top`/`.Bottom`/`.Start`/`.End`/`.Auto`) — с какой стороны от + триггера появляется тело. +- `alignment` (`BubbleAlignment.Start`/`.Center`/`.End`) — выравнивание тела вдоль оси, + перпендикулярной `placement`. + +По умолчанию оба параметра передаются вызывающей стороной, обычно на основе позиции карточки в +сетке. `placement = BubblePlacement.Auto` перекладывает выбор на компонент: в момент +разворота он измеряет свободное место от круга-якоря до границ `BubbleHost` по всем 4 +сторонам и выбирает сторону с максимумом места, а `alignment` (переданный отдельным параметром в +этом режиме игнорируется) подбирает так же — по свободному месту вдоль перпендикулярной оси. Это +эвристика по доступному месту, а не гарантия, что тело не наложится на границы хоста — тело растёт +до своего реального размера уже после выбора стороны. + +## Стиль Bubble + +Сейчас доступна единственная стилевая вариация — `Bubble.Default`. Набор размерных/видовых +вариаций появится отдельным изменением при наличии полной дизайн-спеки. Подробнее о том, как работают +`Style`, `StyleBuilder` и stateful-параметры стиля, см. в разделе [Стилизация компонентов](../theme/Styles.md). + + + +### Создание стиля с помощью builder() + +```kotlin +// @sample: com/sdds/plasma/homeds/docs/samples/Bubble_BubbleStyle.kt +``` diff --git a/tokens/plasma.homeds.compose/docs/override-docs/static/screenshots-docusaurus/com_sdds_plasma_homeds_docs_samples_Bubble_Card.png b/tokens/plasma.homeds.compose/docs/override-docs/static/screenshots-docusaurus/com_sdds_plasma_homeds_docs_samples_Bubble_Card.png new file mode 100644 index 0000000000..d38d1e5ba2 Binary files /dev/null and b/tokens/plasma.homeds.compose/docs/override-docs/static/screenshots-docusaurus/com_sdds_plasma_homeds_docs_samples_Bubble_Card.png differ diff --git a/tokens/plasma.homeds.compose/docs/override-docs/static/screenshots-docusaurus/com_sdds_plasma_homeds_docs_samples_Bubble_Default.png b/tokens/plasma.homeds.compose/docs/override-docs/static/screenshots-docusaurus/com_sdds_plasma_homeds_docs_samples_Bubble_Default.png new file mode 100644 index 0000000000..617e6907dc Binary files /dev/null and b/tokens/plasma.homeds.compose/docs/override-docs/static/screenshots-docusaurus/com_sdds_plasma_homeds_docs_samples_Bubble_Default.png differ diff --git a/tokens/plasma.homeds.compose/docs/src/main/kotlin/com/sdds/plasma/homeds/docs/samples/BubbleSamples.kt b/tokens/plasma.homeds.compose/docs/src/main/kotlin/com/sdds/plasma/homeds/docs/samples/BubbleSamples.kt new file mode 100644 index 0000000000..dfe6a6e49a --- /dev/null +++ b/tokens/plasma.homeds.compose/docs/src/main/kotlin/com/sdds/plasma/homeds/docs/samples/BubbleSamples.kt @@ -0,0 +1,130 @@ +package com.sdds.plasma.homeds.docs.samples + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.sdds.compose.uikit.Card +import com.sdds.compose.uikit.Text +import com.sdds.compose.uikit.style.style +import com.sdds.docs.DocSample +import com.sdds.docs.composableCodeSnippet +import com.sdds.icons.R +import com.sdds.plasma.homeds.components.bubble.BubbleHost +import com.sdds.plasma.homeds.components.bubble.BubbleStyle +import com.sdds.plasma.homeds.components.bubble.BubbleTrigger +import com.sdds.plasma.homeds.styles.card.L +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Default +import com.sdds.plasma.homeds.theme.PlasmaHomeDsTheme +import com.sdds.plasma.homeds.styles.card.Card as CardStyles +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Bubble as BubbleStyles + +@Composable +@DocSample(needScreenshot = true) +fun Bubble_Default() { + composableCodeSnippet { + val deviceId = "living-room" + var expanded by remember { mutableStateOf(false) } + var hasOffer by remember { mutableStateOf(true) } + val visibleTriggers = remember { mutableStateMapOf() } + BubbleHost { + if (hasOffer) { + BubbleTrigger( + onExpandedClick = {}, + expanded = expanded, + text = "Прокачать до 100%", + progress = 0.6f, + style = BubbleStyles.Default.style(), + onDismissRequest = { + expanded = false + hasOffer = false + }, + onTriggerClick = { expanded = true }, + onTriggerVisibilityChange = { visible -> + visibleTriggers[deviceId] = visible + }, + ) + } + } + } +} + +@Composable +@DocSample(needScreenshot = true) +fun Bubble_Card() { + composableCodeSnippet { + var expanded by remember { mutableStateOf(false) } + + BubbleHost(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Card( + style = CardStyles.L.style(), + extra = { + BubbleTrigger( + onExpandedClick = { /* переход на экран апсейла */ }, + expanded = expanded, + text = "Прокачать до 100%", + progress = 0.6f, + style = BubbleStyles.Default.style(), + onTriggerClick = { expanded = true }, + onDismissRequest = { expanded = false }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp), + ) + }, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text(text = "Устройство") + } + } + } + } + } +} + +@Composable +@DocSample(needScreenshot = false) +fun Bubble_BubbleStyle() { + composableCodeSnippet { + BubbleStyle.builder() + .colors { + bodyColor(PlasmaHomeDsTheme.colors.surfaceInversePositive) + iconColor(PlasmaHomeDsTheme.colors.textOnDarkPrimary) + textColor(PlasmaHomeDsTheme.colors.textOnDarkPrimary) + } + .dimensions { + circleSize(32.dp) + iconSize(16.dp) + bodyCornerRadius(16.dp) + bodyNearCornerRadius(4.dp) + bodyMinHeight(40.dp) + neckWidth(12.dp) + neckLength(20.dp) + neckFilletRadius(4.dp) + paddingStart(8.dp) + paddingEnd(8.dp) + paddingTop(6.dp) + paddingBottom(8.dp) + } + .starIcon(R.drawable.ic_star_four_fill_16) + .closeIcon(R.drawable.ic_close_24) + .expandDuration(600) + .collapseDuration(600) + .style() + } +} diff --git a/tokens/plasma.homeds.compose/docs/src/test/kotlin/com/sdds/plasma/homeds/ComposeDocScreenshotTest.kt b/tokens/plasma.homeds.compose/docs/src/test/kotlin/com/sdds/plasma/homeds/ComposeDocScreenshotTest.kt index b985b33fc5..099a6508c5 100644 --- a/tokens/plasma.homeds.compose/docs/src/test/kotlin/com/sdds/plasma/homeds/ComposeDocScreenshotTest.kt +++ b/tokens/plasma.homeds.compose/docs/src/test/kotlin/com/sdds/plasma/homeds/ComposeDocScreenshotTest.kt @@ -1,9 +1,9 @@ package com.sdds.plasma.homeds import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers -import com.sdds.compose.docs.ComposableSample import com.sdds.compose.uikit.fixtures.SDK_NUMBER import com.sdds.compose.uikit.fixtures.samples.RoborazziConfigDocs +import com.sdds.docs.ComposableSample import org.junit.Test import org.junit.runner.RunWith import org.robolectric.ParameterizedRobolectricTestRunner diff --git a/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsBubbleVariationsCompose.kt b/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsBubbleVariationsCompose.kt new file mode 100644 index 0000000000..3ccacf02fb --- /dev/null +++ b/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsBubbleVariationsCompose.kt @@ -0,0 +1,21 @@ +@file:Suppress( + "UndocumentedPublicClass", + "UndocumentedPublicProperty", + "UndocumentedPublicFunction", +) + +package com.sdds.plasma.homeds.integration.custom + +import com.sdds.compose.sandbox.ComposeStyleProvider +import com.sdds.compose.sandbox.ComposeStyleReference +import com.sdds.compose.uikit.style.style +import com.sdds.plasma.homeds.components.bubble.BubbleStyle +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Bubble +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Default + +object PlasmaHomedsBubbleVariationsCompose : ComposeStyleProvider() { + override val variations: Map> = + mapOf( + "Bubble.Default" to ComposeStyleReference { Bubble.Default.style() }, + ) +} diff --git a/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsComposeCustomComponents.kt b/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsComposeCustomComponents.kt index 737d986931..27aab1a00d 100644 --- a/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsComposeCustomComponents.kt +++ b/tokens/plasma.homeds.compose/integration/src/main/kotlin/com/sdds/plasma/homeds/integration/custom/PlasmaHomedsComposeCustomComponents.kt @@ -10,6 +10,11 @@ import com.sdds.sandbox.ComponentProvider */ val NumberPanelKey = ComponentKey("NumberPanel", ComponentGroup("Data Entry", 1)) +/** + * Ключ для компонента Bubble + */ +val BubbleKey = ComponentKey("Bubble", ComponentKey.Overlay.group) + /** * Компоненты */ @@ -23,5 +28,11 @@ object PlasmaHomedsComposeCustomComponents : ComponentProvider() { "NumberPanel" to PlasmaHomedsNumberPanelVariationsCompose, ), ), + Component( + BubbleKey, + mapOf( + "Bubble" to PlasmaHomedsBubbleVariationsCompose, + ), + ), ).associateBy { it.key } } diff --git a/tokens/plasma.homeds.compose/screenshots-compose/testCollapsedShowsStarIcon_dark.png b/tokens/plasma.homeds.compose/screenshots-compose/testCollapsedShowsStarIcon_dark.png new file mode 100644 index 0000000000..4bf08aac9f Binary files /dev/null and b/tokens/plasma.homeds.compose/screenshots-compose/testCollapsedShowsStarIcon_dark.png differ diff --git a/tokens/plasma.homeds.compose/screenshots-compose/testCollapsedShowsStarIcon_light.png b/tokens/plasma.homeds.compose/screenshots-compose/testCollapsedShowsStarIcon_light.png new file mode 100644 index 0000000000..2c0c9c32a7 Binary files /dev/null and b/tokens/plasma.homeds.compose/screenshots-compose/testCollapsedShowsStarIcon_light.png differ diff --git a/tokens/plasma.homeds.compose/screenshots-compose/testExpandedBubbleOverlapsNarrowNeighborCards_dark.png b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedBubbleOverlapsNarrowNeighborCards_dark.png new file mode 100644 index 0000000000..dca4bc1d2e Binary files /dev/null and b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedBubbleOverlapsNarrowNeighborCards_dark.png differ diff --git a/tokens/plasma.homeds.compose/screenshots-compose/testExpandedBubbleOverlapsNarrowNeighborCards_light.png b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedBubbleOverlapsNarrowNeighborCards_light.png new file mode 100644 index 0000000000..9f3cf5a238 Binary files /dev/null and b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedBubbleOverlapsNarrowNeighborCards_light.png differ diff --git a/tokens/plasma.homeds.compose/screenshots-compose/testExpandedShowsBubbleBody_dark.png b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedShowsBubbleBody_dark.png new file mode 100644 index 0000000000..e16c0f1a26 Binary files /dev/null and b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedShowsBubbleBody_dark.png differ diff --git a/tokens/plasma.homeds.compose/screenshots-compose/testExpandedShowsBubbleBody_light.png b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedShowsBubbleBody_light.png new file mode 100644 index 0000000000..02e7f60628 Binary files /dev/null and b/tokens/plasma.homeds.compose/screenshots-compose/testExpandedShowsBubbleBody_light.png differ diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleBody.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleBody.kt new file mode 100644 index 0000000000..6d32f2846a --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleBody.kt @@ -0,0 +1,269 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.util.lerp +import com.sdds.compose.uikit.LocalTextBrushProducer +import com.sdds.compose.uikit.LocalTextStyle +import com.sdds.compose.uikit.LocalTintBrushProducer +import com.sdds.compose.uikit.interactions.getValueAsState +import com.sdds.compose.uikit.motion.Motion +import com.sdds.compose.uikit.motion.getBrushAsState +import com.sdds.compose.uikit.motion.getDpAsState +import com.sdds.plasma.homeds.motion.bubble.BubbleMotionStyle + +/** + * Тело развёрнутого бабла (содержимое + бейдж-крестик). Вызывается из `BubbleOverlayItem` + * (`BubbleHost.kt`), которому принадлежат реестр и позиционирование оверлея. + * + * @param entry запись реестра хоста с таймлайном/колбэками/стилем + * @param circleSize диаметр круга-якоря + * @param motion общий Motion оверлея (тело + бейдж-крестик), построенный в `BubbleOverlayItem` + */ +@Composable +internal fun BubbleBody(entry: BubbleOverlayEntry, circleSize: Dp, motion: Motion) { + val style = entry.style + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val interactionSource = motion.context.interactionSource + val bodyCornerRadius by style.dimensions.bodyCornerRadius.getDpAsState( + motion.context, + motion.style.bodyCornerRadius, + ) + val bodyNearCornerRadius by style.dimensions.bodyNearCornerRadius.getDpAsState( + motion.context, + motion.style.bodyNearCornerRadius, + ) + val bodyMinSize by style.dimensions.bodyMinHeight.getDpAsState(motion.context, motion.style.bodyMinHeight) + val neckWidth by style.dimensions.neckWidth.getDpAsState(motion.context, motion.style.neckWidth) + val neckLength by style.dimensions.neckLength.getDpAsState(motion.context, motion.style.neckLength) + val neckFilletRadius by style.dimensions.neckFilletRadius.getDpAsState( + motion.context, + motion.style.neckFilletRadius, + ) + val paddingStart by style.dimensions.paddingStart.getDpAsState(motion.context, motion.style.paddingStart) + val paddingEnd by style.dimensions.paddingEnd.getDpAsState(motion.context, motion.style.paddingEnd) + val paddingTop by style.dimensions.paddingTop.getDpAsState(motion.context, motion.style.paddingTop) + val paddingBottom by style.dimensions.paddingBottom.getDpAsState(motion.context, motion.style.paddingBottom) + val contentPadding = PaddingValues( + start = paddingStart, + end = paddingEnd, + top = paddingTop, + bottom = paddingBottom, + ) + val iconSize by style.dimensions.iconSize.getDpAsState(motion.context, motion.style.iconSize) + val bodyBrush by style.colors.bodyBrush.getBrushAsState(motion.context, motion.style.bodyColor) + val iconBrush by style.colors.iconBrush.getBrushAsState(motion.context, motion.style.iconColor) + val borderBrush by style.colors.borderBrush.getBrushAsState(motion.context, motion.style.borderColor) + val textBrush by style.colors.textColor.getBrushAsState(motion.context, motion.style.textColor) + val textStyle by style.textStyle.getValueAsState(interactionSource) + val glowLayer = style.innerGlowLayer(interactionSource) + val closeInteractionSource = remember { MutableInteractionSource() } + val badgeAlignment = bubbleBadgeAlignment(entry.placement, entry.alignment) + val reserved = with(density) { (circleSize.toPx() + neckLength.toPx()).toDp() } + val reservedPadding = bubbleReservedPadding(entry.placement, reserved) + val bodyMinSizeModifier = when (entry.placement) { + BubblePlacement.Top, BubblePlacement.Bottom -> Modifier.heightIn(min = bodyMinSize) + BubblePlacement.Start, BubblePlacement.End -> Modifier.widthIn(min = bodyMinSize) + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + Box( + modifier = Modifier + .graphicsLayer { + val bounceScale = entry.bounceProgress().coerceAtLeast(1f) + scaleX = bounceScale + scaleY = bounceScale + val (resolvedPlacement, resolvedAlignment) = resolveBubblePlacement( + entry.placement, + entry.alignment, + layoutDirection, + ) + transformOrigin = bubbleBouncePivot( + resolvedPlacement = resolvedPlacement, + resolvedAlignment = resolvedAlignment, + circleRadiusPx = circleSize.toPx() / 2f, + sizeWidth = size.width, + sizeHeight = size.height, + ) + } + .drawWithCache { + val circlePx = circleSize.toPx() + val neckWidthPx = neckWidth.toPx() + val neckLengthPx = neckLength.toPx() + val neckFilletRadiusPx = neckFilletRadius.toPx() + val cornerPx = bodyCornerRadius.toPx() + val nearCornerPx = bodyNearCornerRadius.toPx() + val glowRadiusPx = glowLayer?.blurRadius?.toPx() ?: 0f + val glowPainter = createBubbleInnerGlowPainter(size) + onDrawBehind { + val path = buildBubblePath( + size = size, + layoutDirection = layoutDirection, + progress = entry.expandProgress(), + circleDiameterPx = circlePx, + neckWidthPx = neckWidthPx, + neckLengthPx = neckLengthPx, + neckFilletRadiusPx = neckFilletRadiusPx, + bodyCornerRadiusPx = cornerPx, + bodyNearCornerRadiusPx = nearCornerPx, + placement = entry.placement, + alignment = entry.alignment, + ) + val bodyAlpha = ( + (entry.expandProgress() - BODY_ALPHA_LOW) / (BODY_ALPHA_HIGH - BODY_ALPHA_LOW) + ).coerceIn(0f, 1f) + drawPath(path, bodyBrush, alpha = bodyAlpha) + if (glowLayer != null) { + with(glowPainter) { draw(path, glowLayer.color, glowRadiusPx, alpha = bodyAlpha) } + } + } + } + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = entry.onBodyClick, + ), + ) { + Box(modifier = Modifier.padding(reservedPadding)) { + Box( + modifier = bodyMinSizeModifier + .padding(contentPadding) + .graphicsLayer { alpha = entry.expandProgress().coerceIn(0f, 1f) }, + ) { + CompositionLocalProvider( + LocalTintBrushProducer provides { textBrush }, + LocalTextBrushProducer provides { textBrush }, + LocalTextStyle provides textStyle, + ) { + entry.content() + } + } + } + Box( + modifier = Modifier + .align(badgeAlignment) + .size(circleSize) + .clickable( + interactionSource = closeInteractionSource, + indication = null, + onClick = entry.onCloseClick, + ), + contentAlignment = Alignment.Center, + ) { + BubbleTriggerIcon( + morphProgress = entry.iconMorphProgress, + rotationProgress = entry.rotationProgress, + sizeScale = { lerp(1f, BUBBLE_BADGE_BORDER_SCALE, entry.iconMorphProgress()) }, + tint = iconBrush, + starIcon = style.starIcon, + closeIcon = style.closeIcon, + backgroundBrush = bodyBrush, + borderBrush = borderBrush, + borderSize = circleSize, + glowLayer = glowLayer, + modifier = Modifier.size(iconSize), + ) + } + } +} + +/** + * Позиция круга-якоря внутри overlay-бокса для выравнивания бейджа-крестика через `Modifier.align`. + */ +@Suppress("CyclomaticComplexMethod") +private fun bubbleBadgeAlignment(placement: BubblePlacement, alignment: BubbleAlignment): Alignment { + val horizontal = when (placement) { + BubblePlacement.Start -> Alignment.End + BubblePlacement.End -> Alignment.Start + BubblePlacement.Top, BubblePlacement.Bottom -> when (alignment) { + BubbleAlignment.Start -> Alignment.Start + BubbleAlignment.Center -> Alignment.CenterHorizontally + BubbleAlignment.End -> Alignment.End + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + val vertical = when (placement) { + BubblePlacement.Top -> Alignment.Bottom + BubblePlacement.Bottom -> Alignment.Top + BubblePlacement.Start, BubblePlacement.End -> when (alignment) { + BubbleAlignment.Start -> Alignment.Top + BubbleAlignment.Center -> Alignment.CenterVertically + BubbleAlignment.End -> Alignment.Bottom + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + return Alignment { size, space, layoutDirection -> + IntOffset( + horizontal.align(size.width, space.width, layoutDirection), + vertical.align(size.height, space.height), + ) + } +} + +/** + * На какой стороне content-слота резервировать место под круг+шейку. + */ +private fun bubbleReservedPadding(placement: BubblePlacement, reserved: Dp): PaddingValues = + when (placement) { + BubblePlacement.Top -> PaddingValues(bottom = reserved) + BubblePlacement.Bottom -> PaddingValues(top = reserved) + BubblePlacement.Start -> PaddingValues(end = reserved) + BubblePlacement.End -> PaddingValues(start = reserved) + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + +/** + * Пивот для "пружинного" `graphicsLayer`-масштаба тела — центр круга-якоря. + */ +@Suppress("CyclomaticComplexMethod") +private fun bubbleBouncePivot( + resolvedPlacement: BubblePlacement, + resolvedAlignment: BubbleAlignment, + circleRadiusPx: Float, + sizeWidth: Float, + sizeHeight: Float, +): TransformOrigin { + val pivotX = when (resolvedPlacement) { + BubblePlacement.Start -> 1f - circleRadiusPx / sizeWidth + BubblePlacement.End -> circleRadiusPx / sizeWidth + BubblePlacement.Top, BubblePlacement.Bottom -> when (resolvedAlignment) { + BubbleAlignment.Start -> circleRadiusPx / sizeWidth + BubbleAlignment.Center -> 0.5f + BubbleAlignment.End -> 1f - circleRadiusPx / sizeWidth + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + val pivotY = when (resolvedPlacement) { + BubblePlacement.Top -> 1f - circleRadiusPx / sizeHeight + BubblePlacement.Bottom -> circleRadiusPx / sizeHeight + BubblePlacement.Start, BubblePlacement.End -> when (resolvedAlignment) { + BubbleAlignment.Start -> circleRadiusPx / sizeHeight + BubbleAlignment.Center -> 0.5f + BubbleAlignment.End -> 1f - circleRadiusPx / sizeHeight + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + return TransformOrigin(pivotX, pivotY) +} + +private const val BODY_ALPHA_LOW = 0.3f +private const val BODY_ALPHA_HIGH = 0.7f diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleHost.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleHost.kt new file mode 100644 index 0000000000..d6e2fd05f6 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleHost.kt @@ -0,0 +1,172 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.zIndex +import com.sdds.compose.uikit.motion.getDpAsState +import com.sdds.compose.uikit.motion.rememberMotionContext +import com.sdds.plasma.homeds.motion.bubble.rememberBubbleMotion +import kotlin.math.roundToInt + +/** + * Состояние [BubbleHost] — реестр развёрнутых/анимирующихся баблов, зарегистрированных их + * триггерами. + */ +@Stable +public class BubbleHostState internal constructor() { + + internal var rootCoordinates: LayoutCoordinates? by mutableStateOf(null) + + internal val entries = mutableStateMapOf() + + internal fun register(entry: BubbleOverlayEntry) { + entries[entry.key] = entry + } + + internal fun unregister(key: Any) { + entries.remove(key) + } +} + +/** + * Создаёт и запоминает [BubbleHostState]. + */ +@Composable +public fun rememberBubbleHostState(): BubbleHostState = remember { BubbleHostState() } + +/** + * CompositionLocal с текущим [BubbleHostState]. `null`, если [BubbleHost] не смонтирован + * выше по дереву. + */ +public val LocalBubbleHostState: ProvidableCompositionLocal = + staticCompositionLocalOf { null } + +/** + * Контейнер вокруг сетки устройств, владеющий позиционированием развёрнутых апсейл-баблов. + * + * Монтируется один раз вокруг всей сетки. Развёрнутый бабл рисуется этим компонентом поверх + * [content], так он не обрезается клипом отдельной карточки. Несколько триггеров регистрируются + * независимо; Host не ограничивает количество одновременно развёрнутых баблов. + * + * @param modifier модификатор контейнера + * @param state состояние-реестр, см. [rememberBubbleHostState] + * @param content содержимое сетки устройств + */ +@Composable +public fun BubbleHost( + modifier: Modifier = Modifier, + state: BubbleHostState = rememberBubbleHostState(), + content: @Composable () -> Unit, +) { + DisposableEffect(state) { + onDispose { state.rootCoordinates = null } + } + CompositionLocalProvider(LocalBubbleHostState provides state) { + Box( + modifier = modifier.onGloballyPositioned { state.rootCoordinates = it }, + ) { + content() + state.entries.values.forEach { entry -> + key(entry.key) { + BubbleOverlayItem(entry) + } + } + } + } +} + +/** + * Запись реестра [BubbleHostState] — одна активная (анимирующая или развёрнутая) карточка. + */ +internal class BubbleOverlayEntry( + val key: Any, + val anchorPosition: () -> Offset, + val expandProgress: () -> Float, + val iconMorphProgress: () -> Float, + val rotationProgress: () -> Float, + val bounceProgress: () -> Float, + val placement: BubblePlacement, + val alignment: BubbleAlignment, + val style: BubbleStyle, + val onBodyClick: () -> Unit, + val onCloseClick: () -> Unit, + val content: @Composable () -> Unit, +) + +@Composable +private fun BubbleOverlayItem(entry: BubbleOverlayEntry) { + val density = LocalDensity.current + val interactionSource = remember { MutableInteractionSource() } + val motion = rememberBubbleMotion(motionContext = rememberMotionContext(interactionSource)) + val circleSize by entry.style.dimensions.circleSize.getDpAsState(motion.context, motion.style.circleSize) + val circlePx = with(density) { circleSize.toPx() } + Box( + modifier = Modifier + .zIndex(1f) + .bubbleOverlayPlacement( + anchorPosition = entry.anchorPosition, + circlePx = circlePx, + placement = entry.placement, + alignment = entry.alignment, + ), + ) { + BubbleBody(entry, circleSize, motion) + } +} + +/** + * Позиционирует overlay-бокс (круг+тело+шейка целиком) так, чтобы круг-якорь внутри него совпал с + * [anchorPosition]. + */ +@Suppress("CyclomaticComplexMethod") +private fun Modifier.bubbleOverlayPlacement( + anchorPosition: () -> Offset, + circlePx: Float, + placement: BubblePlacement, + alignment: BubbleAlignment, +): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val anchor = anchorPosition() + val (resolvedPlacement, resolvedAlignment) = resolveBubblePlacement(placement, alignment, layoutDirection) + val x = when (resolvedPlacement) { + BubblePlacement.Start -> anchor.x + circlePx - placeable.width + BubblePlacement.End -> anchor.x + BubblePlacement.Top, BubblePlacement.Bottom -> when (resolvedAlignment) { + BubbleAlignment.Start -> anchor.x + BubbleAlignment.Center -> anchor.x - (placeable.width - circlePx) / 2f + BubbleAlignment.End -> anchor.x + circlePx - placeable.width + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + val y = when (resolvedPlacement) { + BubblePlacement.Top -> anchor.y + circlePx - placeable.height + BubblePlacement.Bottom -> anchor.y + BubblePlacement.Start, BubblePlacement.End -> when (resolvedAlignment) { + BubbleAlignment.Start -> anchor.y + BubbleAlignment.Center -> anchor.y - (placeable.height - circlePx) / 2f + BubbleAlignment.End -> anchor.y + circlePx - placeable.height + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + layout(placeable.width, placeable.height) { + placeable.placeRelative(x.roundToInt(), y.roundToInt()) + } +} diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleInnerGlowPainter.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleInnerGlowPainter.kt new file mode 100644 index 0000000000..e16d0c3333 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleInnerGlowPainter.kt @@ -0,0 +1,71 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.toArgb +import com.sdds.compose.uikit.interactions.getValue +import com.sdds.compose.uikit.shadow.ShadowLayer +import kotlin.math.roundToInt + +/** + * Рисовальщик внутренней тени (inner shadow) поверх уже нарисованной заливки фигуры. Использует + * `android.graphics.BlurMaskFilter` на принудительно программном `Bitmap`/`Canvas`, чтобы размытие + * работало одинаково и при программном, и при аппаратном рендере слоя. Держатель bitmap/canvas + * создаётся один раз на размер контейнера (см. [createBubbleInnerGlowPainter]), перерисовывается — + * на каждый кадр (см. [BubbleInnerGlowPainter.draw]). + */ +internal class BubbleInnerGlowPainter(width: Int, height: Int) { + + private val bitmap = android.graphics.Bitmap.createBitmap( + width.coerceAtLeast(1), + height.coerceAtLeast(1), + android.graphics.Bitmap.Config.ARGB_8888, + ) + private val canvas = android.graphics.Canvas(bitmap) + private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + style = android.graphics.Paint.Style.STROKE + } + private var cachedBlurRadiusPx = Float.NaN + private var cachedMaskFilter: android.graphics.BlurMaskFilter? = null + + fun DrawScope.draw(path: Path, color: Color, blurRadiusPx: Float, alpha: Float = 1f) { + if (blurRadiusPx <= 0f || alpha <= 0f) return + bitmap.eraseColor(android.graphics.Color.TRANSPARENT) + val androidPath = path.asAndroidPath() + canvas.save() + canvas.clipPath(androidPath) + paint.color = color.toArgb() + paint.strokeWidth = blurRadiusPx + if (cachedBlurRadiusPx != blurRadiusPx) { + cachedBlurRadiusPx = blurRadiusPx + cachedMaskFilter = android.graphics.BlurMaskFilter( + blurRadiusPx, + android.graphics.BlurMaskFilter.Blur.NORMAL, + ) + } + paint.maskFilter = cachedMaskFilter + canvas.drawPath(androidPath, paint) + canvas.restore() + drawImage(bitmap.asImageBitmap(), alpha = alpha) + } +} + +/** + * Создаёт [BubbleInnerGlowPainter] под фактический размер контейнера — вызывать из + * `drawWithCache` (один раз на изменение размера), не из `onDrawBehind`. + */ +internal fun createBubbleInnerGlowPainter(size: Size): BubbleInnerGlowPainter = + BubbleInnerGlowPainter(size.width.roundToInt(), size.height.roundToInt()) + +/** + * Первый слой [BubbleStyle.shadow], резолвленный для [interactionSource]. `null`, если слоёв нет. + */ +@Composable +internal fun BubbleStyle.innerGlowLayer(interactionSource: InteractionSource): ShadowLayer? = + shadow.getValue(interactionSource).layers.firstOrNull() diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleOvershootEasing.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleOvershootEasing.kt new file mode 100644 index 0000000000..05f270960f --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleOvershootEasing.kt @@ -0,0 +1,27 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.animation.core.Easing + +/** + * "Пружинный" overshoot-easing (`easeOutBack`): значение проскакивает мимо цели и плавно + * возвращается к ней. [tension] управляет силой перелёта. + */ +internal class BubbleOvershootEasing(private val tension: Float) : Easing { + override fun transform(fraction: Float): Float { + val u = fraction - 1f + return 1f + u * u * ((tension + 1f) * u + tension) + } +} + +/** + * Овершут для "пружинного" масштаба тела при разворачивании — ~10% перелёта мимо цели. + */ +internal val BubbleBounceEasing = BubbleOvershootEasing(BUBBLE_BOUNCE_TENSION) + +/** + * Овершут для поворота обводки — ~15% перелёта мимо цели. + */ +internal val BubbleRotationOvershootEasing = BubbleOvershootEasing(BUBBLE_ROTATION_OVERSHOOT_TENSION) + +private const val BUBBLE_BOUNCE_TENSION = 1.70158f +private const val BUBBLE_ROTATION_OVERSHOOT_TENSION = 2.163f diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleProgress.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleProgress.kt new file mode 100644 index 0000000000..5408fe5b39 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleProgress.kt @@ -0,0 +1,84 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Path + +/** + * Прогресс-бар для перегрузки [BubbleTrigger]: два прямоугольника с разрывом + * между ними, положение разрыва показывает прогресс. + * + * @param progress прогресс + * @param style стиль компонента — читает [BubbleDimensions.progressHeight], + * [BubbleDimensions.progressPointerThickness], [BubbleDimensions.progressCornerRadius], + * [BubbleColors.progressTrackBrush], [BubbleColors.progressValueBrush] + * @param modifier модификатор + */ +@Composable +internal fun BubbleProgress( + progress: Float, + style: BubbleStyle, + modifier: Modifier = Modifier, +) { + val height = style.dimensions.progressHeight.getDefaultValue() + val pointerThickness = style.dimensions.progressPointerThickness.getDefaultValue() + val cornerRadius = style.dimensions.progressCornerRadius.getDefaultValue() + val trackBrush = style.colors.progressTrackBrush.getDefaultValue() + val valueBrush = style.colors.progressValueBrush.getDefaultValue() + val clampedProgress = progress.coerceIn(0f, 1f) + Box( + modifier = modifier + .height(height) + .drawWithCache { + val thicknessPx = pointerThickness.toPx() + val halfThicknessPx = (thicknessPx / 2f).coerceAtMost(size.width / 2f) + val gapCenterPx = (clampedProgress * size.width).coerceIn(halfThicknessPx, size.width - halfThicknessPx) + val gapLeftPx = gapCenterPx - halfThicknessPx + val gapRightPx = gapCenterPx + halfThicknessPx + val cornerRadiusPx = cornerRadius.toPx().coerceAtMost(size.height / 2f) + val valuePath = if (gapLeftPx > 0f) { + val outerRadius = CornerRadius(cornerRadiusPx.coerceAtMost(gapLeftPx / 2f)) + Path().apply { + addRoundRect( + RoundRect( + rect = Rect(0f, 0f, gapLeftPx, size.height), + topLeft = outerRadius, + bottomLeft = outerRadius, + topRight = CornerRadius.Zero, + bottomRight = CornerRadius.Zero, + ), + ) + } + } else { + null + } + val trackPath = if (gapRightPx < size.width) { + val trackWidthPx = size.width - gapRightPx + val outerRadius = CornerRadius(cornerRadiusPx.coerceAtMost(trackWidthPx / 2f)) + Path().apply { + addRoundRect( + RoundRect( + rect = Rect(gapRightPx, 0f, size.width, size.height), + topLeft = CornerRadius.Zero, + bottomLeft = CornerRadius.Zero, + topRight = outerRadius, + bottomRight = outerRadius, + ), + ) + } + } else { + null + } + onDrawBehind { + valuePath?.let { drawPath(it, valueBrush) } + trackPath?.let { drawPath(it, trackBrush) } + } + }, + ) +} diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleShape.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleShape.kt new file mode 100644 index 0000000000..0dbfe63f36 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleShape.kt @@ -0,0 +1,317 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathOperation +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.util.lerp + +/** + * Строит Union-геометрию апсейл-бабла: круг-якорь, объединённый с телом, растущим из круга при + * [progress] от 0 (только круг) до 1 (круг + тело в полный размер [size]). + * + * @param size полный размер контейнера + * @param layoutDirection направление раскладки — используется для зеркалирования [placement]/[alignment] + * @param progress прогресс разворота 0f..1f + * @param circleDiameterPx диаметр круга-якоря в px + * @param neckWidthPx ширина шейки в развёрнутом состоянии, px + * @param neckLengthPx длина шейки (зазор между кругом и телом) в развёрнутом состоянии, px + * @param neckFilletRadiusPx радиус вогнутой галтели на стыках шейки, px + * @param bodyCornerRadiusPx радиус скругления обычных углов тела, px + * @param bodyNearCornerRadiusPx радиус скругления угла тела, ближайшего к кругу-якорю, px + * @param placement с какой стороны от круга-якоря появляется тело + * @param alignment выравнивание тела вдоль оси, перпендикулярной [placement] + */ +@Suppress("LongParameterList") +internal fun buildBubblePath( + size: Size, + layoutDirection: LayoutDirection, + progress: Float, + circleDiameterPx: Float, + neckWidthPx: Float, + neckLengthPx: Float, + neckFilletRadiusPx: Float, + bodyCornerRadiusPx: Float, + bodyNearCornerRadiusPx: Float, + placement: BubblePlacement, + alignment: BubbleAlignment, +): Path { + val (resolvedPlacement, resolvedAlignment) = resolveBubblePlacement(placement, alignment, layoutDirection) + + val canonicalSize = when (resolvedPlacement) { + BubblePlacement.Top, BubblePlacement.Bottom -> size + BubblePlacement.Start, BubblePlacement.End -> Size(size.height, size.width) + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + val path = buildCanonicalBubblePath( + size = canonicalSize, + progress = progress, + circleDiameterPx = circleDiameterPx, + neckWidthPx = neckWidthPx, + neckLengthPx = neckLengthPx, + neckFilletRadiusPx = neckFilletRadiusPx, + bodyCornerRadiusPx = bodyCornerRadiusPx, + bodyNearCornerRadiusPx = bodyNearCornerRadiusPx, + alignment = resolvedAlignment, + ) + val matrix = placementMatrix(resolvedPlacement, size) + if (matrix != null) { + path.transform(matrix) + } + return path +} + +/** + * Матрица преобразования канонического (Top-ориентированного) `Path` в реальную ориентацию + * [placement] — `null` для [BubblePlacement.Top]. + */ +private fun placementMatrix(placement: BubblePlacement, realSize: Size): Matrix? = when (placement) { + BubblePlacement.Top -> null + BubblePlacement.Bottom -> Matrix().apply { + this[1, 1] = -1f + this[3, 1] = realSize.height + } + BubblePlacement.Start -> Matrix().apply { + this[0, 0] = 0f + this[0, 1] = 1f + this[1, 0] = 1f + this[1, 1] = 0f + } + BubblePlacement.End -> Matrix().apply { + this[0, 0] = 0f + this[0, 1] = 1f + this[1, 0] = -1f + this[1, 1] = 0f + this[3, 0] = realSize.width + } + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() +} + +/** + * Резолвит [placement]/[alignment] под [layoutDirection]. + */ +internal fun resolveBubblePlacement( + placement: BubblePlacement, + alignment: BubbleAlignment, + layoutDirection: LayoutDirection, +): Pair { + val isRtl = layoutDirection == LayoutDirection.Rtl + val resolvedPlacement = if (isRtl) mirrorPlacementRtl(placement) else placement + val resolvedAlignment = if (isRtl && (placement == BubblePlacement.Top || placement == BubblePlacement.Bottom)) { + mirrorAlignmentRtl(alignment) + } else { + alignment + } + return resolvedPlacement to resolvedAlignment +} + +private fun mirrorPlacementRtl(placement: BubblePlacement): BubblePlacement = when (placement) { + BubblePlacement.Start -> BubblePlacement.End + BubblePlacement.End -> BubblePlacement.Start + BubblePlacement.Top, BubblePlacement.Bottom -> placement + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() +} + +/** + * Точка отказа для exhaustive `when` над [BubblePlacement], если [BubblePlacement.Auto] не был + * разрешён заранее (см. [resolveAutoBubblePlacement]). + */ +internal fun unresolvedBubbleAutoPlacementError(): Nothing = + error( + "BubblePlacement.Auto должен быть разрешён в конкретную сторону " + + "(resolveAutoBubblePlacement) до того, как достигнет геометрии/layout — " + + "см. регистрацию BubbleOverlayEntry в BubbleTrigger.kt", + ) + +/** + * Автовыбор [placement]/[alignment] по свободному месту в границах [BubbleHost] — реализация + * [BubblePlacement.Auto]. + * + * @param hostSize размер [BubbleHost] в px + * @param anchorPosition позиция круга-якоря (левый верхний угол) в координатах хоста, px + * @param circleDiameterPx диаметр круга-якоря, px + * @param layoutDirection направление раскладки + */ +internal fun resolveAutoBubblePlacement( + hostSize: Size, + anchorPosition: Offset, + circleDiameterPx: Float, + layoutDirection: LayoutDirection, +): Pair { + if (hostSize.width <= 0f || hostSize.height <= 0f) { + return BubblePlacement.Top to BubbleAlignment.End + } + val spaceTop = anchorPosition.y + val spaceBottom = hostSize.height - (anchorPosition.y + circleDiameterPx) + val spaceLeft = anchorPosition.x + val spaceRight = hostSize.width - (anchorPosition.x + circleDiameterPx) + val isRtl = layoutDirection == LayoutDirection.Rtl + val spaceStart = if (isRtl) spaceRight else spaceLeft + val spaceEnd = if (isRtl) spaceLeft else spaceRight + + val placement = listOf( + BubblePlacement.Top to spaceTop, + BubblePlacement.Bottom to spaceBottom, + BubblePlacement.Start to spaceStart, + BubblePlacement.End to spaceEnd, + ).maxByOrNull { it.second }!!.first + + val alignment = when (placement) { + BubblePlacement.Top, BubblePlacement.Bottom -> autoBubbleAlignment(spaceStart, spaceEnd) + BubblePlacement.Start, BubblePlacement.End -> autoBubbleAlignment(spaceTop, spaceBottom) + BubblePlacement.Auto -> unresolvedBubbleAutoPlacementError() + } + return placement to alignment +} + +/** + * Выбирает [BubbleAlignment] по разнице свободного места на двух концах перпендикулярной оси. + */ +private fun autoBubbleAlignment(spaceAtStart: Float, spaceAtEnd: Float): BubbleAlignment { + val diff = spaceAtEnd - spaceAtStart + val threshold = AUTO_ALIGNMENT_CENTER_THRESHOLD_RATIO * maxOf(spaceAtStart, spaceAtEnd, 1f) + return when { + diff > threshold -> BubbleAlignment.Start + diff < -threshold -> BubbleAlignment.End + else -> BubbleAlignment.Center + } +} + +private const val AUTO_ALIGNMENT_CENTER_THRESHOLD_RATIO = 0.15f + +private fun mirrorAlignmentRtl(alignment: BubbleAlignment): BubbleAlignment = when (alignment) { + BubbleAlignment.Start -> BubbleAlignment.End + BubbleAlignment.End -> BubbleAlignment.Start + BubbleAlignment.Center -> alignment +} + +/** + * Каноническая геометрия — круг-якорь у нижнего края [size], тело растёт вверх, [alignment] + * управляет горизонтальной позицией круга. + */ +@Suppress("LongParameterList", "CyclomaticComplexMethod") +private fun buildCanonicalBubblePath( + size: Size, + progress: Float, + circleDiameterPx: Float, + neckWidthPx: Float, + neckLengthPx: Float, + neckFilletRadiusPx: Float, + bodyCornerRadiusPx: Float, + bodyNearCornerRadiusPx: Float, + alignment: BubbleAlignment, +): Path { + val p = progress.coerceIn(0f, 1f) + + val circleLeft = when (alignment) { + BubbleAlignment.Start -> 0f + BubbleAlignment.Center -> (size.width - circleDiameterPx) / 2f + BubbleAlignment.End -> size.width - circleDiameterPx + } + val circleTop = size.height - circleDiameterPx + val circleRect = Rect(circleLeft, circleTop, circleLeft + circleDiameterPx, circleTop + circleDiameterPx) + val circleCenterX = circleLeft + circleDiameterPx / 2f + + if (p <= 0f) { + return Path().apply { addOval(circleRect) } + } + + val bodyFinalRect = Rect(0f, 0f, size.width, size.height - circleDiameterPx - neckLengthPx) + val bodyRect = Rect( + left = lerp(circleRect.left, bodyFinalRect.left, p), + top = lerp(circleRect.top, bodyFinalRect.top, p), + right = lerp(circleRect.right, bodyFinalRect.right, p), + bottom = lerp(circleRect.bottom, bodyFinalRect.bottom, p), + ) + val overlapPx = neckWidthPx * NECK_OVERLAP_RATIO + val neckTopY = bodyRect.bottom + val neckBottomY = lerp(circleRect.top, circleRect.top + overlapPx, p) + val neckIncluded = neckTopY < neckBottomY + val maxSafeCorner = minOf(bodyRect.width, bodyRect.height) / 2f + val cornerBlend = ((p - CORNER_SHARPEN_THRESHOLD) / (1f - CORNER_SHARPEN_THRESHOLD)).coerceIn(0f, 1f) + val lerpedCorner = lerp(maxSafeCorner, bodyCornerRadiusPx, cornerBlend).coerceAtMost(maxSafeCorner) + val marginToFillet = (circleDiameterPx / 2f - neckWidthPx / 2f - neckFilletRadiusPx).coerceAtLeast(0f) + val safeNearCornerPx = bodyNearCornerRadiusPx.coerceAtMost(marginToFillet * 0.8f).coerceAtMost(maxSafeCorner) + val nearCornerBlendWindowPx = neckFilletRadiusPx.coerceAtLeast(1f) + val nearCornerBlend = ((neckBottomY - neckTopY + nearCornerBlendWindowPx) / (2f * nearCornerBlendWindowPx)) + .coerceIn(0f, 1f) + val nearCornerRoundTarget = lerp(maxSafeCorner, minOf(maxSafeCorner, marginToFillet * 0.8f), nearCornerBlend) + val nearCorner = lerp(nearCornerRoundTarget, safeNearCornerPx, cornerBlend).coerceAtMost(maxSafeCorner) + val farCorner = CornerRadius(lerpedCorner, lerpedCorner) + val nearCornerRadius = CornerRadius(nearCorner, nearCorner) + val roundRect = when (alignment) { + BubbleAlignment.Start -> RoundRect( + bodyRect, + topLeft = farCorner, + topRight = farCorner, + bottomRight = farCorner, + bottomLeft = nearCornerRadius, + ) + BubbleAlignment.End -> RoundRect( + bodyRect, + topLeft = farCorner, + topRight = farCorner, + bottomRight = nearCornerRadius, + bottomLeft = farCorner, + ) + BubbleAlignment.Center -> RoundRect( + bodyRect, + topLeft = farCorner, + topRight = farCorner, + bottomRight = farCorner, + bottomLeft = farCorner, + ) + } + val bodyPath = Path().apply { addRoundRect(roundRect) } + + val circlePath = Path().apply { addOval(circleRect) } + val bodyUnionCircle = Path().apply { op(bodyPath, circlePath, PathOperation.Union) } + if (!neckIncluded) { + return bodyUnionCircle + } + val neckHalfWidth = lerp(circleDiameterPx / 2f, neckWidthPx / 2f, p) + val filletRadius = lerp(0f, neckFilletRadiusPx, p) + val neckPath = buildNeckPath( + centerX = circleCenterX, + topY = neckTopY, + bottomY = neckBottomY, + halfWidth = neckHalfWidth, + filletRadius = filletRadius, + ) + return Path().apply { op(bodyUnionCircle, neckPath, PathOperation.Union) } +} + +/** + * Строит прямоугольную шейку шириной 2×[halfWidth] со скруглёнными вогнутыми галтелями радиуса + * [filletRadius] на обоих стыках (с телом и с кругом-якорем). + */ +private fun buildNeckPath( + centerX: Float, + topY: Float, + bottomY: Float, + halfWidth: Float, + filletRadius: Float, +): Path { + val leftX = centerX - halfWidth + val rightX = centerX + halfWidth + val fr = filletRadius + return Path().apply { + moveTo(leftX - fr, topY) + arcTo(Rect(leftX - 2 * fr, topY, leftX, topY + 2 * fr), 270f, 90f, false) + lineTo(leftX, bottomY - fr) + arcTo(Rect(leftX - 2 * fr, bottomY - 2 * fr, leftX, bottomY), 0f, 90f, false) + lineTo(rightX + fr, bottomY) + arcTo(Rect(rightX, bottomY - 2 * fr, rightX + 2 * fr, bottomY), 90f, 90f, false) + lineTo(rightX, topY + fr) + arcTo(Rect(rightX, topY, rightX + 2 * fr, topY + 2 * fr), 180f, 90f, false) + close() + } +} + +private const val NECK_OVERLAP_RATIO = 0.25f +private const val CORNER_SHARPEN_THRESHOLD = 0.85f diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleStarFourShape.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleStarFourShape.kt new file mode 100644 index 0000000000..6ebaf53484 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleStarFourShape.kt @@ -0,0 +1,101 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection + +/** + * Силуэт «пухлой» 4-конечной звезды-бейджа триггера апсейл-бабла и его декоративной обводки — + * точные абсолютные координаты пути из Figma-макета, масштабируемые под фактический размер. + * Путь описан в системе координат 32×32. + */ +internal object BubbleStarFourShape : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline { + val scale = size.width / VIEWPORT_SIZE + val path = Path().apply { + moveTo(10.3273f * scale, 3.66236f * scale) + cubicTo( + 12.5328f * scale, + -1.22079f * scale, + 19.4672f * scale, + -1.22079f * scale, + 21.6727f * scale, + 3.66236f * scale, + ) + lineTo(22.7785f * scale, 6.11086f * scale) + cubicTo( + 23.4022f * scale, + 7.49179f * scale, + 24.5082f * scale, + 8.59779f * scale, + 25.8891f * scale, + 9.22148f * scale, + ) + lineTo(28.3376f * scale, 10.3273f * scale) + cubicTo( + 33.2208f * scale, + 12.5328f * scale, + 33.2208f * scale, + 19.4672f * scale, + 28.3376f * scale, + 21.6727f * scale, + ) + lineTo(25.8891f * scale, 22.7785f * scale) + cubicTo( + 24.5082f * scale, + 23.4022f * scale, + 23.4022f * scale, + 24.5082f * scale, + 22.7785f * scale, + 25.8891f * scale, + ) + lineTo(21.6727f * scale, 28.3376f * scale) + cubicTo( + 19.4672f * scale, + 33.2208f * scale, + 12.5328f * scale, + 33.2208f * scale, + 10.3273f * scale, + 28.3376f * scale, + ) + lineTo(9.22148f * scale, 25.8891f * scale) + cubicTo( + 8.59779f * scale, + 24.5082f * scale, + 7.49179f * scale, + 23.4022f * scale, + 6.11086f * scale, + 22.7785f * scale, + ) + lineTo(3.66236f * scale, 21.6727f * scale) + cubicTo( + -1.22079f * scale, + 19.4672f * scale, + -1.22079f * scale, + 12.5328f * scale, + 3.66236f * scale, + 10.3273f * scale, + ) + lineTo(6.11086f * scale, 9.22148f * scale) + cubicTo( + 7.49179f * scale, + 8.59779f * scale, + 8.59779f * scale, + 7.49179f * scale, + 9.22148f * scale, + 6.11086f * scale, + ) + close() + } + return Outline.Generic(path) + } +} + +private const val VIEWPORT_SIZE = 32f diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleState.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleState.kt new file mode 100644 index 0000000000..5451406d7e --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleState.kt @@ -0,0 +1,82 @@ +package com.sdds.plasma.homeds.components.bubble + +/** + * С какой стороны от триггера появляется тело бабла — терминология popover/tooltip (аналог + * `side` у Radix/Floating UI), а не полный компас направлений: тело всегда растёт ОТ триггера + * строго в одну из 4 сторон, а не по диагонали — диагональные варианты (напр. "сверху и с + * выравниванием влево") задаются комбинацией [BubblePlacement] и [BubbleAlignment]. + * + * [Start]/[End] зеркалируются под RTL (см. вызывающую сторону) — как и полагается сторонам, + * заданным относительно направления текста, а не абсолютно "влево"/"вправо". + * + * По умолчанию сторону передаёт вызывающая сторона (например, на основе номера колонки карточки в + * сетке) — но можно передать [Auto] и переложить выбор на компонент, см. его KDoc. + */ +public enum class BubblePlacement { + + /** + * Тело бабла появляется НАД триггером (круг-якорь снизу). + */ + Top, + + /** + * Тело бабла появляется ПОД триггером (круг-якорь сверху). + */ + Bottom, + + /** + * Тело бабла появляется СБОКУ от триггера, со стороны начала (слева в LTR). + */ + Start, + + /** + * Тело бабла появляется СБОКУ от триггера, со стороны конца (справа в LTR). + */ + End, + + /** + * Автовыбор стороны по свободному месту в границах [BubbleHost] — компонент сам измеряет + * расстояние от круга-якоря до всех 4 границ хоста и выбирает сторону с максимумом свободного + * места (эвристика, не гарантия отсутствия наложения на границы хоста — тело растёт до своего + * реального размера уже после выбора стороны, что-то вроде `flip`-мидлвара в popover-библиотеках). + * В этом режиме [BubbleAlignment], переданный отдельным параметром, ИГНОРИРУЕТСЯ — он тоже + * подбирается автоматически по той же логике (свободное место вдоль перпендикулярной оси). + * + * `Auto` — это стратегия выбора, а не сторона: значение никогда не доходит до геометрии/layout + * компонента, [BubbleTrigger] разрешает его в конкретную сторону+выравнивание ДО передачи + * дальше по системе. + */ + Auto, +} + +/** + * Выравнивание тела бабла вдоль оси, ПЕРПЕНДИКУЛЯРНОЙ направлению роста ([BubblePlacement]) — + * терминология popover/tooltip (аналог `align` у Radix/Floating UI). Для [BubblePlacement.Top]/ + * [BubblePlacement.Bottom] это горизонтальная ось (тело растёт вверх/вниз, выравнивание — влево/ + * вправо/по центру); для [BubblePlacement.Start]/[BubblePlacement.End] — вертикальная + * (тело растёт вбок, выравнивание — вверх/вниз/по центру). + * + * [Start]/[End] на горизонтальной оси зеркалируются под RTL; на вертикальной оси RTL не влияет + * (направление текста не меняет верх/низ). + */ +public enum class BubbleAlignment { + + /** + * Тело выровнено по ближней (к началу оси) стороне круга-якоря — тот же угол тела, что и у + * круга, остаётся ближним; шейка примыкает к углу тела, ближнему к кругу-якорю, и получает + * уменьшенный радиус скругления (см. `BubbleDimensions.bodyNearCornerRadius`). + */ + Start, + + /** + * Тело центрировано относительно круга-якоря вдоль перпендикулярной оси — шейка примыкает к + * середине ближнего края тела, оба соседних угла получают ОБЫЧНЫЙ (не уменьшенный) радиус: + * ни один из них не является геометрически «ближним» к шейке однозначно. + */ + Center, + + /** + * Тело выровнено по дальней (от начала оси) стороне круга-якоря — зеркало [Start]. + */ + End, +} diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleStyle.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleStyle.kt new file mode 100644 index 0000000000..7279ac8aac --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleStyle.kt @@ -0,0 +1,730 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.sdds.compose.uikit.graphics.brush.asStatefulBrush +import com.sdds.compose.uikit.interactions.StatefulValue +import com.sdds.compose.uikit.interactions.asStatefulBrush +import com.sdds.compose.uikit.interactions.asStatefulValue +import com.sdds.compose.uikit.shadow.ShadowAppearance +import com.sdds.compose.uikit.shadow.ShadowLayer +import com.sdds.compose.uikit.style.Style +import com.sdds.compose.uikit.style.StyleBuilder +import com.sdds.icons.R + +/** + * CompositionLocal c [BubbleStyle] для компонентов [BubbleTrigger]/[BubbleHost]. + */ +public val LocalBubbleStyle = + compositionLocalOf { BubbleStyle.builder().style() } + +/** + * Стиль компонента апсейл-бабла на карточке устройства. + * + * На данном этапе публично доступна только одна готовая вариация — [BubbleStyle.Companion.Default] + * (см. `styles/customcomponents/bubble/BubbleStyles.kt`); набор размерных/видовых вариаций + * будет добавлен отдельным изменением при появлении полной дизайн-спеки. + */ +@Stable +public interface BubbleStyle : Style { + + /** + * Цвета компонента + * @see BubbleColors + */ + public val colors: BubbleColors + + /** + * Размеры компонента + * @see BubbleDimensions + */ + public val dimensions: BubbleDimensions + + /** + * Типографика текста содержимого развёрнутого бабла. + */ + public val textStyle: StatefulValue + + /** + * Внутренняя тень (inner glow) заливки триггера/тела. + */ + public val shadow: StatefulValue + + /** + * Иконка триггера в свёрнутом состоянии (звёздочка). + */ + @get:DrawableRes + public val starIcon: Int + + /** + * Иконка триггера в развёрнутом состоянии (крестик, кнопка закрытия). + */ + @get:DrawableRes + public val closeIcon: Int + + /** + * Длительность анимации разворачивания тела бабла (мс) — таймлайн `expandProgress`/ + * `iconMorph`/`rotationProgress`/`bounceProgress` в [BubbleTrigger]. + */ + public val expandDurationMillis: Int + + /** + * Длительность анимации сворачивания тела бабла (мс), см. [expandDurationMillis]. + */ + public val collapseDurationMillis: Int + + public companion object { + + /** + * Возвращает экземпляр [BubbleStyleBuilder] + */ + public fun builder(): BubbleStyleBuilder = DefaultBubbleStyle.Builder() + } +} + +/** + * Builder стиля [BubbleStyle]. + */ +@Stable +public interface BubbleStyleBuilder : StyleBuilder { + + /** + * Устанавливает цвета компонента при помощи [builder] + * @see BubbleStyle.colors + */ + @Composable + public fun colors(builder: @Composable BubbleColorsBuilder.() -> Unit): BubbleStyleBuilder + + /** + * Устанавливает размеры и отступы компонента при помощи [builder] + * @see BubbleStyle.dimensions + */ + @Composable + public fun dimensions(builder: @Composable BubbleDimensionsBuilder.() -> Unit): BubbleStyleBuilder + + /** @see BubbleStyle.textStyle */ + public fun textStyle(textStyle: TextStyle): BubbleStyleBuilder = textStyle(textStyle.asStatefulValue()) + + /** @see BubbleStyle.textStyle */ + public fun textStyle(textStyle: StatefulValue): BubbleStyleBuilder + + /** @see BubbleStyle.shadow */ + public fun shadow(shadow: ShadowAppearance): BubbleStyleBuilder = shadow(shadow.asStatefulValue()) + + /** @see BubbleStyle.shadow */ + public fun shadow(shadow: StatefulValue): BubbleStyleBuilder + + /** @see BubbleStyle.starIcon */ + public fun starIcon(@DrawableRes icon: Int): BubbleStyleBuilder + + /** @see BubbleStyle.closeIcon */ + public fun closeIcon(@DrawableRes icon: Int): BubbleStyleBuilder + + /** @see BubbleStyle.expandDurationMillis */ + public fun expandDuration(durationMillis: Int): BubbleStyleBuilder + + /** @see BubbleStyle.collapseDurationMillis */ + public fun collapseDuration(durationMillis: Int): BubbleStyleBuilder +} + +/** + * Цвета компонента апсейл-бабла. + */ +@Stable +public interface BubbleColors { + + /** + * Кисть заливки тела бабла (и свёрнутого триггера — единая заливка Union-формы). + */ + public val bodyBrush: StatefulValue + + /** + * Кисть иконки триггера (звёздочка/крестик). + */ + public val iconBrush: StatefulValue + + /** + * Кисть декоративной обводки-«мерцания» вокруг иконки триггера (см. Figma: узел + * "Trigger"/"Star 3") — тонкая полупрозрачная рамка поверх круга-якоря/тела, без собственной + * заливки. Поворачивается при морфинге иконки (см. [BubbleTriggerIcon]). + */ + public val borderBrush: StatefulValue + + /** + * Цвет текста, предоставляемый слоту содержимого развёрнутого бабла. + */ + public val textColor: StatefulValue + + /** + * Кисть незаполненной части прогресс-бара (см. [BubbleTrigger] — перегрузка с `text`/ + * `progress` вместо content-слота). + */ + public val progressTrackBrush: StatefulValue + + /** + * Кисть заполненной (пройденной) части прогресс-бара. + */ + public val progressValueBrush: StatefulValue + + public companion object { + + /** + * Возвращает экземпляр [BubbleColorsBuilder] + */ + public fun builder(): BubbleColorsBuilder = DefaultBubbleColors.Builder() + } +} + +/** + * Builder для [BubbleColors]. + */ +@Stable +public interface BubbleColorsBuilder { + + /** @see BubbleColors.bodyBrush */ + public fun bodyColor(color: Color): BubbleColorsBuilder = bodyColor(color.asStatefulBrush()) + + /** @see BubbleColors.bodyBrush */ + public fun bodyColor(brush: Brush): BubbleColorsBuilder = bodyColor(brush.asStatefulValue()) + + /** @see BubbleColors.bodyBrush */ + public fun bodyColor(brush: StatefulValue): BubbleColorsBuilder + + /** @see BubbleColors.iconBrush */ + public fun iconColor(color: Color): BubbleColorsBuilder = iconColor(color.asStatefulBrush()) + + /** @see BubbleColors.iconBrush */ + public fun iconColor(brush: Brush): BubbleColorsBuilder = iconColor(brush.asStatefulValue()) + + /** @see BubbleColors.iconBrush */ + public fun iconColor(brush: StatefulValue): BubbleColorsBuilder + + /** @see BubbleColors.borderBrush */ + public fun borderColor(color: Color): BubbleColorsBuilder = borderColor(color.asStatefulBrush()) + + /** @see BubbleColors.borderBrush */ + public fun borderColor(brush: Brush): BubbleColorsBuilder = borderColor(brush.asStatefulValue()) + + /** @see BubbleColors.borderBrush */ + public fun borderColor(brush: StatefulValue): BubbleColorsBuilder + + /** @see BubbleColors.textColor */ + public fun textColor(color: Color): BubbleColorsBuilder = textColor(color.asStatefulBrush()) + + /** @see BubbleColors.textColor */ + public fun textColor(brush: Brush): BubbleColorsBuilder = textColor(brush.asStatefulValue()) + + /** @see BubbleColors.textColor */ + public fun textColor(brush: StatefulValue): BubbleColorsBuilder + + /** @see BubbleColors.progressTrackBrush */ + public fun progressTrackColor(color: Color): BubbleColorsBuilder = progressTrackColor(color.asStatefulBrush()) + + /** @see BubbleColors.progressTrackBrush */ + public fun progressTrackColor(brush: Brush): BubbleColorsBuilder = progressTrackColor(brush.asStatefulValue()) + + /** @see BubbleColors.progressTrackBrush */ + public fun progressTrackColor(brush: StatefulValue): BubbleColorsBuilder + + /** @see BubbleColors.progressValueBrush */ + public fun progressValueColor(color: Color): BubbleColorsBuilder = progressValueColor(color.asStatefulBrush()) + + /** @see BubbleColors.progressValueBrush */ + public fun progressValueColor(brush: Brush): BubbleColorsBuilder = progressValueColor(brush.asStatefulValue()) + + /** @see BubbleColors.progressValueBrush */ + public fun progressValueColor(brush: StatefulValue): BubbleColorsBuilder + + /** + * Возвращает готовый экземпляр [BubbleColors] + */ + public fun build(): BubbleColors +} + +@Immutable +private class DefaultBubbleColors( + override val bodyBrush: StatefulValue, + override val iconBrush: StatefulValue, + override val borderBrush: StatefulValue, + override val textColor: StatefulValue, + override val progressTrackBrush: StatefulValue, + override val progressValueBrush: StatefulValue, +) : BubbleColors { + + class Builder : BubbleColorsBuilder { + private var bodyBrush: StatefulValue? = null + private var iconBrush: StatefulValue? = null + private var borderBrush: StatefulValue? = null + private var textColor: StatefulValue? = null + private var progressTrackBrush: StatefulValue? = null + private var progressValueBrush: StatefulValue? = null + + override fun bodyColor(brush: StatefulValue): BubbleColorsBuilder = apply { + this.bodyBrush = brush + } + + override fun iconColor(brush: StatefulValue): BubbleColorsBuilder = apply { + this.iconBrush = brush + } + + override fun borderColor(brush: StatefulValue): BubbleColorsBuilder = apply { + this.borderBrush = brush + } + + override fun textColor(brush: StatefulValue): BubbleColorsBuilder = apply { + this.textColor = brush + } + + override fun progressTrackColor(brush: StatefulValue): BubbleColorsBuilder = apply { + this.progressTrackBrush = brush + } + + override fun progressValueColor(brush: StatefulValue): BubbleColorsBuilder = apply { + this.progressValueBrush = brush + } + + override fun build(): BubbleColors { + val body = bodyBrush ?: Color.Green.asStatefulBrush() + return DefaultBubbleColors( + bodyBrush = body, + iconBrush = iconBrush ?: Color.White.asStatefulBrush(), + borderBrush = borderBrush ?: Color.White.copy(alpha = 0.12f).asStatefulBrush(), + textColor = textColor ?: Color.White.asStatefulBrush(), + progressTrackBrush = progressTrackBrush ?: Color.White.copy(alpha = 0.24f).asStatefulBrush(), + progressValueBrush = progressValueBrush ?: Color.White.asStatefulBrush(), + ) + } + } +} + +/** + * Размеры компонента апсейл-бабла. + */ +@Stable +public interface BubbleDimensions { + + /** + * Диаметр свёрнутого триггера (и круга-якоря в основании развёрнутого бабла). + */ + public val circleSize: StatefulValue + + /** + * Размер иконки (звёздочка/крестик) внутри круга-якоря. + */ + public val iconSize: StatefulValue + + /** + * Радиус скругления трёх «дальних» углов тела развёрнутого бабла (не считая угла, ближайшего + * к кругу-якорю — см. [bodyNearCornerRadius]). + */ + public val bodyCornerRadius: StatefulValue + + /** + * Радиус скругления угла тела, ближайшего к кругу-якорю/шейке. Меньше [bodyCornerRadius]. + * Внутренне ограничивается сверху, чтобы дуга не пересекалась с дугой [neckFilletRadius] + * (см. `buildBubblePath`). + */ + public val bodyNearCornerRadius: StatefulValue + + /** + * Минимальная высота тела развёрнутого бабла. + */ + public val bodyMinHeight: StatefulValue + + /** + * Рекомендуемая ширина тела развёрнутого бабла — используется перегрузкой [BubbleTrigger] + * с `text`/`progress`. Не используется перегрузкой с произвольным content-слотом. + */ + public val width: StatefulValue + + /** + * Ширина «шейки», соединяющей круг-якорь с телом бабла в полностью развёрнутом состоянии. + */ + public val neckWidth: StatefulValue + + /** + * Длина «шейки» — реальный зазор между кругом-якорем и телом в полностью развёрнутом состоянии. + */ + public val neckLength: StatefulValue + + /** + * Радиус вогнутой галтели на стыке «шейки» с телом бабла. На стыке «шейки» с кругом-якорем + * галтель не рисуется (радиус там всегда 0) — на Figma-макете плавность именно у тела, а не + * у круга. + */ + public val neckFilletRadius: StatefulValue + + /** + * Внутренний отступ содержимого (content-слота) тела бабла от начального (левого в LTR) края. + */ + public val paddingStart: StatefulValue + + /** + * Внутренний отступ содержимого (content-слота) тела бабла от конечного (правого в LTR) края. + */ + public val paddingEnd: StatefulValue + + /** + * Внутренний отступ содержимого (content-слота) тела бабла от верхнего края. + */ + public val paddingTop: StatefulValue + + /** + * Внутренний отступ содержимого (content-слота) тела бабла от нижнего края. + */ + public val paddingBottom: StatefulValue + + /** + * Высота прогресс-бара (см. [BubbleTrigger] — перегрузка с `text`/`progress` вместо + * content-слота). + */ + public val progressHeight: StatefulValue + + /** + * Отступ между текстом (строкой текст+шеврон) и прогресс-баром под ней — та же перегрузка + * [BubbleTrigger], что и [progressHeight]. Не используется, если прогресс-бар скрыт + * (`hasProgress = false`). + */ + public val progressSpacing: StatefulValue + + /** + * Толщина указателя значения прогресса — разрыва между заполненной (пройденной) и трековой + * частями прогресс-бара, чья позиция и показывает `progress` перегрузки [BubbleTrigger] + * с `text`/`progress` (см. `BubbleProgress`). Считается вдоль той же оси, что и общая + * ширина бара (а не [progressHeight] — та ось перпендикулярна). + */ + public val progressPointerThickness: StatefulValue + + /** + * Радиус скругления крайних (внешних, "пилюльных") торцов сегментов прогресс-бара + * (см. `BubbleProgress`) — торцы у разрыва-указателя прямые. Внутренне ограничивается сверху + * половиной [progressHeight] и половиной ширины сегмента. + */ + public val progressCornerRadius: StatefulValue + + /** + * Насколько иконка-шеврон (перегрузка [BubbleTrigger] с `text`/`progress`) заезжает на + * последнее слово текста — иконка встроена в поток текста как inline-контент (привязана к + * последнему слову последней строки, переносится вместе с ней) вплотную, без зазора, и + * дополнительно сдвигается на это расстояние В СТОРОНУ текста (навстречу, а не от него). + */ + public val chevronTextOverlap: StatefulValue + + /** + * Размер иконки-шеврона (перегрузка [BubbleTrigger] с `text`/`progress`). + */ + public val chevronIconSize: StatefulValue + + public companion object { + + /** + * Возвращает экземпляр [BubbleDimensionsBuilder] + */ + public fun builder(): BubbleDimensionsBuilder = DefaultBubbleDimensions.Builder() + } +} + +/** + * Builder для [BubbleDimensions]. + */ +@Stable +public interface BubbleDimensionsBuilder { + + /** @see BubbleDimensions.circleSize */ + public fun circleSize(size: Dp): BubbleDimensionsBuilder = circleSize(size.asStatefulValue()) + + /** @see BubbleDimensions.circleSize */ + public fun circleSize(size: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.iconSize */ + public fun iconSize(size: Dp): BubbleDimensionsBuilder = iconSize(size.asStatefulValue()) + + /** @see BubbleDimensions.iconSize */ + public fun iconSize(size: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.bodyCornerRadius */ + public fun bodyCornerRadius(radius: Dp): BubbleDimensionsBuilder = bodyCornerRadius(radius.asStatefulValue()) + + /** @see BubbleDimensions.bodyCornerRadius */ + public fun bodyCornerRadius(radius: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.bodyNearCornerRadius */ + public fun bodyNearCornerRadius(radius: Dp): BubbleDimensionsBuilder = + bodyNearCornerRadius(radius.asStatefulValue()) + + /** @see BubbleDimensions.bodyNearCornerRadius */ + public fun bodyNearCornerRadius(radius: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.bodyMinHeight */ + public fun bodyMinHeight(height: Dp): BubbleDimensionsBuilder = bodyMinHeight(height.asStatefulValue()) + + /** @see BubbleDimensions.bodyMinHeight */ + public fun bodyMinHeight(height: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.width */ + public fun width(width: Dp): BubbleDimensionsBuilder = width(width.asStatefulValue()) + + /** @see BubbleDimensions.width */ + public fun width(width: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.neckWidth */ + public fun neckWidth(width: Dp): BubbleDimensionsBuilder = neckWidth(width.asStatefulValue()) + + /** @see BubbleDimensions.neckWidth */ + public fun neckWidth(width: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.neckLength */ + public fun neckLength(length: Dp): BubbleDimensionsBuilder = neckLength(length.asStatefulValue()) + + /** @see BubbleDimensions.neckLength */ + public fun neckLength(length: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.neckFilletRadius */ + public fun neckFilletRadius(radius: Dp): BubbleDimensionsBuilder = + neckFilletRadius(radius.asStatefulValue()) + + /** @see BubbleDimensions.neckFilletRadius */ + public fun neckFilletRadius(radius: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.paddingStart */ + public fun paddingStart(padding: Dp): BubbleDimensionsBuilder = paddingStart(padding.asStatefulValue()) + + /** @see BubbleDimensions.paddingStart */ + public fun paddingStart(padding: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.paddingEnd */ + public fun paddingEnd(padding: Dp): BubbleDimensionsBuilder = paddingEnd(padding.asStatefulValue()) + + /** @see BubbleDimensions.paddingEnd */ + public fun paddingEnd(padding: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.paddingTop */ + public fun paddingTop(padding: Dp): BubbleDimensionsBuilder = paddingTop(padding.asStatefulValue()) + + /** @see BubbleDimensions.paddingTop */ + public fun paddingTop(padding: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.paddingBottom */ + public fun paddingBottom(padding: Dp): BubbleDimensionsBuilder = paddingBottom(padding.asStatefulValue()) + + /** @see BubbleDimensions.paddingBottom */ + public fun paddingBottom(padding: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.progressHeight */ + public fun progressHeight(height: Dp): BubbleDimensionsBuilder = progressHeight(height.asStatefulValue()) + + /** @see BubbleDimensions.progressHeight */ + public fun progressHeight(height: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.progressSpacing */ + public fun progressSpacing(spacing: Dp): BubbleDimensionsBuilder = progressSpacing(spacing.asStatefulValue()) + + /** @see BubbleDimensions.progressSpacing */ + public fun progressSpacing(spacing: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.progressPointerThickness */ + public fun progressPointerThickness(thickness: Dp): BubbleDimensionsBuilder = + progressPointerThickness(thickness.asStatefulValue()) + + /** @see BubbleDimensions.progressPointerThickness */ + public fun progressPointerThickness(thickness: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.progressCornerRadius */ + public fun progressCornerRadius(radius: Dp): BubbleDimensionsBuilder = + progressCornerRadius(radius.asStatefulValue()) + + /** @see BubbleDimensions.progressCornerRadius */ + public fun progressCornerRadius(radius: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.chevronTextOverlap */ + public fun chevronTextOverlap(overlap: Dp): BubbleDimensionsBuilder = + chevronTextOverlap(overlap.asStatefulValue()) + + /** @see BubbleDimensions.chevronTextOverlap */ + public fun chevronTextOverlap(overlap: StatefulValue): BubbleDimensionsBuilder + + /** @see BubbleDimensions.chevronIconSize */ + public fun chevronIconSize(size: Dp): BubbleDimensionsBuilder = chevronIconSize(size.asStatefulValue()) + + /** @see BubbleDimensions.chevronIconSize */ + public fun chevronIconSize(size: StatefulValue): BubbleDimensionsBuilder + + /** + * Возвращает готовый экземпляр [BubbleDimensions] + */ + public fun build(): BubbleDimensions +} + +@Immutable +private class DefaultBubbleDimensions( + override val circleSize: StatefulValue, + override val iconSize: StatefulValue, + override val bodyCornerRadius: StatefulValue, + override val bodyNearCornerRadius: StatefulValue, + override val bodyMinHeight: StatefulValue, + override val width: StatefulValue, + override val neckWidth: StatefulValue, + override val neckLength: StatefulValue, + override val neckFilletRadius: StatefulValue, + override val paddingStart: StatefulValue, + override val paddingEnd: StatefulValue, + override val paddingTop: StatefulValue, + override val paddingBottom: StatefulValue, + override val progressHeight: StatefulValue, + override val progressSpacing: StatefulValue, + override val progressPointerThickness: StatefulValue, + override val progressCornerRadius: StatefulValue, + override val chevronTextOverlap: StatefulValue, + override val chevronIconSize: StatefulValue, +) : BubbleDimensions { + + class Builder : BubbleDimensionsBuilder { + private var circleSize: StatefulValue? = null + private var iconSize: StatefulValue? = null + private var bodyCornerRadius: StatefulValue? = null + private var bodyNearCornerRadius: StatefulValue? = null + private var bodyMinHeight: StatefulValue? = null + private var width: StatefulValue? = null + private var neckWidth: StatefulValue? = null + private var neckLength: StatefulValue? = null + private var neckFilletRadius: StatefulValue? = null + private var paddingStart: StatefulValue? = null + private var paddingEnd: StatefulValue? = null + private var paddingTop: StatefulValue? = null + private var paddingBottom: StatefulValue? = null + private var progressHeight: StatefulValue? = null + private var progressSpacing: StatefulValue? = null + private var progressPointerThickness: StatefulValue? = null + private var progressCornerRadius: StatefulValue? = null + private var chevronTextOverlap: StatefulValue? = null + private var chevronIconSize: StatefulValue? = null + + override fun circleSize(size: StatefulValue) = apply { this.circleSize = size } + override fun iconSize(size: StatefulValue) = apply { this.iconSize = size } + override fun bodyCornerRadius(radius: StatefulValue) = apply { this.bodyCornerRadius = radius } + override fun bodyNearCornerRadius(radius: StatefulValue) = apply { this.bodyNearCornerRadius = radius } + override fun bodyMinHeight(height: StatefulValue) = apply { this.bodyMinHeight = height } + override fun width(width: StatefulValue) = apply { this.width = width } + override fun neckWidth(width: StatefulValue) = apply { this.neckWidth = width } + override fun neckLength(length: StatefulValue) = apply { this.neckLength = length } + override fun neckFilletRadius(radius: StatefulValue) = apply { this.neckFilletRadius = radius } + override fun paddingStart(padding: StatefulValue) = apply { this.paddingStart = padding } + override fun paddingEnd(padding: StatefulValue) = apply { this.paddingEnd = padding } + override fun paddingTop(padding: StatefulValue) = apply { this.paddingTop = padding } + override fun paddingBottom(padding: StatefulValue) = apply { this.paddingBottom = padding } + override fun progressHeight(height: StatefulValue) = apply { this.progressHeight = height } + override fun progressSpacing(spacing: StatefulValue) = apply { this.progressSpacing = spacing } + override fun progressPointerThickness(thickness: StatefulValue) = apply { + this.progressPointerThickness = thickness + } + override fun progressCornerRadius(radius: StatefulValue) = apply { this.progressCornerRadius = radius } + override fun chevronTextOverlap(overlap: StatefulValue) = apply { this.chevronTextOverlap = overlap } + override fun chevronIconSize(size: StatefulValue) = apply { this.chevronIconSize = size } + + @Suppress("CyclomaticComplexMethod") + override fun build(): BubbleDimensions = DefaultBubbleDimensions( + circleSize = circleSize ?: 32.dp.asStatefulValue(), + iconSize = iconSize ?: 16.dp.asStatefulValue(), + bodyCornerRadius = bodyCornerRadius ?: 12.dp.asStatefulValue(), + bodyNearCornerRadius = bodyNearCornerRadius ?: 6.dp.asStatefulValue(), + bodyMinHeight = bodyMinHeight ?: 40.dp.asStatefulValue(), + width = width ?: 100.dp.asStatefulValue(), + neckWidth = neckWidth ?: 12.dp.asStatefulValue(), + neckLength = neckLength ?: 4.dp.asStatefulValue(), + neckFilletRadius = neckFilletRadius ?: 4.dp.asStatefulValue(), + paddingStart = paddingStart ?: 8.dp.asStatefulValue(), + paddingEnd = paddingEnd ?: 8.dp.asStatefulValue(), + paddingTop = paddingTop ?: 6.dp.asStatefulValue(), + paddingBottom = paddingBottom ?: 8.dp.asStatefulValue(), + progressHeight = progressHeight ?: 4.dp.asStatefulValue(), + progressSpacing = progressSpacing ?: 8.dp.asStatefulValue(), + progressPointerThickness = progressPointerThickness ?: 4.dp.asStatefulValue(), + progressCornerRadius = progressCornerRadius ?: 2.dp.asStatefulValue(), + chevronTextOverlap = chevronTextOverlap ?: 3.dp.asStatefulValue(), + chevronIconSize = chevronIconSize ?: 16.dp.asStatefulValue(), + ) + } +} + +@Immutable +internal class DefaultBubbleStyle( + override val colors: BubbleColors, + override val dimensions: BubbleDimensions, + override val textStyle: StatefulValue, + override val shadow: StatefulValue, + @get:DrawableRes override val starIcon: Int, + @get:DrawableRes override val closeIcon: Int, + override val expandDurationMillis: Int, + override val collapseDurationMillis: Int, +) : BubbleStyle { + + @Stable + class Builder : BubbleStyleBuilder { + private var colorsBuilder: BubbleColorsBuilder = BubbleColors.builder() + private var dimensionsBuilder: BubbleDimensionsBuilder = BubbleDimensions.builder() + private var textStyle: StatefulValue? = null + private var shadow: StatefulValue? = null + + @DrawableRes private var starIcon: Int? = null + + @DrawableRes private var closeIcon: Int? = null + private var expandDurationMillis: Int? = null + private var collapseDurationMillis: Int? = null + + @Composable + override fun colors(builder: @Composable BubbleColorsBuilder.() -> Unit): BubbleStyleBuilder = + apply { this.colorsBuilder.builder() } + + @Composable + override fun dimensions( + builder: @Composable BubbleDimensionsBuilder.() -> Unit, + ): BubbleStyleBuilder = apply { this.dimensionsBuilder.builder() } + + override fun textStyle(textStyle: StatefulValue) = apply { this.textStyle = textStyle } + + override fun shadow(shadow: StatefulValue) = apply { this.shadow = shadow } + + override fun starIcon(@DrawableRes icon: Int) = apply { this.starIcon = icon } + + override fun closeIcon(@DrawableRes icon: Int) = apply { this.closeIcon = icon } + + override fun expandDuration(durationMillis: Int) = apply { this.expandDurationMillis = durationMillis } + + override fun collapseDuration(durationMillis: Int) = apply { this.collapseDurationMillis = durationMillis } + + @Suppress("CyclomaticComplexMethod") + override fun style(): BubbleStyle = DefaultBubbleStyle( + colors = colorsBuilder.build(), + dimensions = dimensionsBuilder.build(), + textStyle = textStyle ?: TextStyle.Default.asStatefulValue(), + shadow = shadow ?: ShadowAppearance( + listOf( + ShadowLayer( + color = Color(0xFFD0FF77).copy(alpha = 0.6f), + offset = DpOffset.Zero, + spreadRadius = 0.dp, + blurRadius = 4.dp, + ), + ), + ).asStatefulValue(), + starIcon = starIcon ?: R.drawable.ic_star_four_fill_16, + closeIcon = closeIcon ?: R.drawable.ic_close_24, + expandDurationMillis = expandDurationMillis ?: DEFAULT_EXPAND_DURATION_MILLIS, + collapseDurationMillis = collapseDurationMillis ?: DEFAULT_COLLAPSE_DURATION_MILLIS, + ) + } + + private companion object { + private const val DEFAULT_EXPAND_DURATION_MILLIS = 600 + private const val DEFAULT_COLLAPSE_DURATION_MILLIS = 600 + } +} diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleTrigger.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleTrigger.kt new file mode 100644 index 0000000000..aa6643d5ff --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleTrigger.kt @@ -0,0 +1,429 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.annotation.DrawableRes +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.toSize +import com.sdds.compose.uikit.Icon +import com.sdds.compose.uikit.Text +import com.sdds.compose.uikit.motion.getBrushAsState +import com.sdds.compose.uikit.motion.getDpAsState +import com.sdds.compose.uikit.motion.rememberMotionContext +import com.sdds.compose.uikit.resourceImageSource +import com.sdds.icons.R +import com.sdds.plasma.homeds.motion.bubble.rememberBubbleMotion +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +/** + * Триггер апсейл-бабла на карточке устройства. + * + * Кладётся внутрь карточки как обычный дочерний composable. Требует смонтированного выше по дереву + * [BubbleHost] (см. [LocalBubbleHostState]) — развёрнутый бабл рисуется хостом поверх сетки, а не + * внутри самого триггера. Без хоста триггер остаётся обычной свёрнутой иконкой и не показывает + * overlay-body даже при `expanded = true`. + * + * Наличие всего Bubble управляется монтированием компонента, а состояние body полностью задаётся + * controlled-параметром [expanded]. Callback-и только сообщают о действиях пользователя и не + * меняют состояние самостоятельно. + * + * @param onExpandedClick колбэк клика по телу развёрнутого бабла (переход на экран апсейла) + * @param expanded раскрыто ли тело бабла + * @param modifier модификатор + * @param placement с какой стороны от триггера появляется тело бабла, см. [BubblePlacement]. + * [BubblePlacement.Auto] перекладывает выбор на компонент + * @param alignment выравнивание тела вдоль оси, перпендикулярной [placement], см. [BubbleAlignment]. + * Игнорируется при [BubblePlacement.Auto] + * @param style стиль компонента + * @param onDismissRequest запрос на закрытие по клику на крестик; состояние меняет вызывающая сторона + * @param onTriggerClick колбэк клика по свёрнутому триггеру; состояние меняет вызывающая сторона + * @param onTriggerVisibilityChange вызывается только при изменении полной видимости триггера в + * границах Host; после `true` сообщает `false` также при отсоединении или удалении триггера + * @param contentDescription описание для доступности на кликабельной иконке-триггере + * @param content содержимое развёрнутого бабла — произвольный слот вызывающей стороны + */ +@Suppress("CyclomaticComplexMethod") +@Composable +public fun BubbleTrigger( + onExpandedClick: () -> Unit, + expanded: Boolean, + modifier: Modifier = Modifier, + placement: BubblePlacement = BubblePlacement.Top, + alignment: BubbleAlignment = BubbleAlignment.End, + style: BubbleStyle = LocalBubbleStyle.current, + onDismissRequest: (() -> Unit)? = null, + onTriggerClick: (() -> Unit)? = null, + onTriggerVisibilityChange: ((Boolean) -> Unit)? = null, + contentDescription: String? = null, + content: @Composable () -> Unit, +) { + val hostState = LocalBubbleHostState.current + val key = remember { Any() } + val badgeInteractionSource = remember { MutableInteractionSource() } + val badgeMotion = rememberBubbleMotion(motionContext = rememberMotionContext(badgeInteractionSource)) + val circleSize by style.dimensions.circleSize.getDpAsState(badgeMotion.context, badgeMotion.style.circleSize) + + val expandProgress = remember { Animatable(0f) } + val iconMorph = remember { Animatable(0f) } + val rotationProgress = remember { Animatable(0f) } + val bounceProgress = remember { Animatable(0f) } + var anchorPosition by remember { mutableStateOf(Offset.Zero) } + val triggerCoordinatesRef = remember { MutableRef(null) } + var triggerVisible by remember { mutableStateOf(false) } + + LaunchedEffect(expanded) { + animateBubbleExpansion( + expanded = expanded, + expandProgress = expandProgress, + iconMorph = iconMorph, + rotationProgress = rotationProgress, + bounceProgress = bounceProgress, + expandDurationMillis = style.expandDurationMillis, + collapseDurationMillis = style.collapseDurationMillis, + ) + } + + val currentExpanded = rememberUpdatedState(expanded) + val currentPlacement = rememberUpdatedState(placement) + val currentAlignment = rememberUpdatedState(alignment) + val currentStyle = rememberUpdatedState(style) + val currentContent = rememberUpdatedState(content) + val currentOnExpandedClick = rememberUpdatedState(onExpandedClick) + val currentOnDismissRequest = rememberUpdatedState(onDismissRequest) + val currentOnTriggerVisibilityChange = rememberUpdatedState(onTriggerVisibilityChange) + val currentDensity = rememberUpdatedState(LocalDensity.current) + val currentLayoutDirection = rememberUpdatedState(LocalLayoutDirection.current) + val rootCoordinates = hostState?.rootCoordinates + + fun updateTriggerVisibility(coordinates: LayoutCoordinates?) { + val visible = coordinates.isFullyVisibleIn(rootCoordinates) + if (visible != triggerVisible) { + triggerVisible = visible + currentOnTriggerVisibilityChange.value?.invoke(visible) + } + } + + // Ключ только rootCoordinates (меняется редко — поворот/инсеты/ресайз хоста): onGloballyPositioned + // ниже и так синхронно пересчитывает видимость при каждом изменении позиции самого триггера, + // а triggerCoordinates — новый объект на каждый layout-проход (в т.ч. каждый кадр скролла сетки), + // поэтому не должен быть ключом/Snapshot-state — иначе пересоздание корутины и рекомпозиция + // BubbleTrigger на каждый кадр скролла для каждого триггера в списке. + LaunchedEffect(rootCoordinates) { + updateTriggerVisibility(triggerCoordinatesRef.value) + } + + DisposableEffect(hostState, key) { + onDispose { + hostState?.unregister(key) + if (triggerVisible) { + triggerVisible = false + currentOnTriggerVisibilityChange.value?.invoke(false) + } + } + } + + if (hostState != null) { + LaunchedEffect(hostState, key) { + snapshotFlow { currentExpanded.value || expandProgress.value > 0f } + .distinctUntilChanged() + .collect { visible -> + if (visible) { + val (resolvedPlacement, resolvedAlignment) = if ( + currentPlacement.value == BubblePlacement.Auto + ) { + resolveAutoBubblePlacement( + hostSize = hostState.rootCoordinates?.size?.toSize() ?: Size.Zero, + anchorPosition = anchorPosition, + circleDiameterPx = with(currentDensity.value) { circleSize.toPx() }, + layoutDirection = currentLayoutDirection.value, + ) + } else { + currentPlacement.value to currentAlignment.value + } + hostState.register( + BubbleOverlayEntry( + key = key, + anchorPosition = { anchorPosition }, + expandProgress = { expandProgress.value }, + iconMorphProgress = { iconMorph.value }, + rotationProgress = { rotationProgress.value }, + bounceProgress = { bounceProgress.value }, + placement = resolvedPlacement, + alignment = resolvedAlignment, + style = currentStyle.value, + onBodyClick = { currentOnExpandedClick.value() }, + onCloseClick = { currentOnDismissRequest.value?.invoke() }, + content = { currentContent.value() }, + ), + ) + } else { + hostState.unregister(key) + } + } + } + } + + Box( + modifier = modifier + .size(circleSize) + .onGloballyPositioned { coordinates -> + triggerCoordinatesRef.value = coordinates + val root = hostState?.rootCoordinates + if (root != null && root.isAttached) { + anchorPosition = root.localPositionOf(coordinates, Offset.Zero) + } + updateTriggerVisibility(coordinates) + }, + ) { + val bodyBrush by style.colors.bodyBrush.getBrushAsState(badgeMotion.context, badgeMotion.style.bodyColor) + val iconBrush by style.colors.iconBrush.getBrushAsState(badgeMotion.context, badgeMotion.style.iconColor) + val borderBrush by style.colors.borderBrush.getBrushAsState(badgeMotion.context, badgeMotion.style.borderColor) + val iconSize by style.dimensions.iconSize.getDpAsState(badgeMotion.context, badgeMotion.style.iconSize) + val glowLayer = style.innerGlowLayer(badgeInteractionSource) + Box( + modifier = Modifier + .size(circleSize) + .graphicsLayer { + alpha = if (expanded) 0f else 1f + } + .then( + if (contentDescription != null) { + Modifier.semantics { this.contentDescription = contentDescription } + } else { + Modifier + }, + ) + .clickable( + interactionSource = badgeInteractionSource, + indication = null, + onClick = { + if (!expanded) { + onTriggerClick?.invoke() + } + }, + ) + .drawWithCache { + val outline = BubbleStarFourShape.createOutline(size, layoutDirection, this) + val path = (outline as Outline.Generic).path + val glowRadiusPx = glowLayer?.blurRadius?.toPx() ?: 0f + val glowPainter = createBubbleInnerGlowPainter(size) + onDrawBehind { + drawPath(path, bodyBrush) + if (glowLayer != null) { + with(glowPainter) { draw(path, glowLayer.color, glowRadiusPx) } + } + } + }, + contentAlignment = Alignment.Center, + ) { + BubbleTriggerIcon( + morphProgress = { 0f }, + rotationProgress = { 0f }, + sizeScale = { 1f }, + tint = iconBrush, + starIcon = style.starIcon, + closeIcon = style.closeIcon, + backgroundBrush = bodyBrush, + borderBrush = borderBrush, + borderSize = circleSize, + glowLayer = glowLayer, + modifier = Modifier.size(iconSize), + ) + } + } +} + +/** + * Перегрузка [BubbleTrigger] без произвольного content-слота: текст + иконка-шеврон + прогресс-бар. + * Делегирует основной перегрузке. + * + * @param onExpandedClick см. основную перегрузку + * @param expanded см. основную перегрузку + * @param text текст содержимого развёрнутого бабла + * @param progress прогресс `0f..1f` (значения вне диапазона — зажимаются), отображается прогресс-баром + * под текстом — см. [BubbleProgress]. Не читается, если `hasProgress = false` + * @param hasProgress показывать ли прогресс-бар + * @param modifier см. основную перегрузку + * @param placement см. основную перегрузку + * @param alignment см. основную перегрузку + * @param style см. основную перегрузку + * @param onDismissRequest см. основную перегрузку + * @param onTriggerClick см. основную перегрузку + * @param onTriggerVisibilityChange см. основную перегрузку + * @param contentDescription см. основную перегрузку + * @param chevronIcon иконка-шеврон справа от текста + */ +@Composable +public fun BubbleTrigger( + onExpandedClick: () -> Unit, + expanded: Boolean, + text: String, + progress: Float, + modifier: Modifier = Modifier, + hasProgress: Boolean = true, + placement: BubblePlacement = BubblePlacement.Top, + alignment: BubbleAlignment = BubbleAlignment.End, + style: BubbleStyle = LocalBubbleStyle.current, + onDismissRequest: (() -> Unit)? = null, + onTriggerClick: (() -> Unit)? = null, + onTriggerVisibilityChange: ((Boolean) -> Unit)? = null, + contentDescription: String? = null, + @DrawableRes chevronIcon: Int = R.drawable.ic_disclosure_right_outline_16, +) { + BubbleTrigger( + onExpandedClick = onExpandedClick, + expanded = expanded, + modifier = modifier, + placement = placement, + alignment = alignment, + style = style, + onDismissRequest = onDismissRequest, + onTriggerClick = onTriggerClick, + onTriggerVisibilityChange = onTriggerVisibilityChange, + contentDescription = contentDescription, + ) { + BubbleTriggerTextContent( + text = text, + progress = progress, + hasProgress = hasProgress, + chevronIcon = chevronIcon, + style = style, + ) + } +} + +/** + * Стандартное содержимое перегрузки [BubbleTrigger] с `text`/`progress`: текст с иконкой-шевроном, + * встроенной в поток текста как inline-контент, плюс опциональный прогресс-бар под ним. + */ +@Composable +private fun BubbleTriggerTextContent( + text: String, + progress: Float, + hasProgress: Boolean, + @DrawableRes chevronIcon: Int, + style: BubbleStyle, +) { + val density = LocalDensity.current + val chevronOverlap = style.dimensions.chevronTextOverlap.getDefaultValue() + val chevronIconSize = style.dimensions.chevronIconSize.getDefaultValue() + val annotatedText = remember(text) { + buildAnnotatedString { + append(text) + appendInlineContent(CHEVRON_INLINE_CONTENT_ID) + } + } + val inlineContent = remember(chevronIcon, density, chevronOverlap, chevronIconSize) { + val chevronSizeSp = with(density) { chevronIconSize.toSp() } + mapOf( + CHEVRON_INLINE_CONTENT_ID to InlineTextContent( + placeholder = Placeholder( + width = chevronSizeSp, + height = chevronSizeSp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + Icon( + source = resourceImageSource(chevronIcon), + contentDescription = null, + modifier = Modifier + .size(chevronIconSize) + .offset(x = -chevronOverlap), + ) + }, + ), + ) + } + Column(modifier = Modifier.width(style.dimensions.width.getDefaultValue())) { + Text(text = annotatedText, inlineContent = inlineContent) + if (hasProgress) { + Spacer(modifier = Modifier.height(style.dimensions.progressSpacing.getDefaultValue())) + BubbleProgress( + progress = progress, + style = style, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** + * Параллельно анимирует все четыре таймлайна разворота/сворачивания тела бабла к состоянию + * [expanded]. + */ +private suspend fun animateBubbleExpansion( + expanded: Boolean, + expandProgress: Animatable, + iconMorph: Animatable, + rotationProgress: Animatable, + bounceProgress: Animatable, + expandDurationMillis: Int, + collapseDurationMillis: Int, +) { + val target = if (expanded) 1f else 0f + val durationMs = if (expanded) expandDurationMillis else collapseDurationMillis + coroutineScope { + launch { expandProgress.animateTo(target, tween(durationMs, easing = LinearOutSlowInEasing)) } + launch { iconMorph.animateTo(target, tween(durationMs, easing = LinearOutSlowInEasing)) } + launch { rotationProgress.animateTo(target, tween(durationMs, easing = BubbleRotationOvershootEasing)) } + launch { bounceProgress.animateTo(target, tween(durationMs, easing = BubbleBounceEasing)) } + } +} + +private const val CHEVRON_INLINE_CONTENT_ID = "bubbleChevron" + +private fun LayoutCoordinates?.isFullyVisibleIn(hostCoordinates: LayoutCoordinates?): Boolean { + if (this == null || hostCoordinates == null) return false + if (!isAttached || !hostCoordinates.isAttached) return false + val bounds = hostCoordinates.localBoundingBoxOf(this, clipBounds = false) + val hasArea = bounds.width > 0f && bounds.height > 0f + val isHorizontallyInside = bounds.left >= 0f && bounds.right <= hostCoordinates.size.width + val isVerticallyInside = bounds.top >= 0f && bounds.bottom <= hostCoordinates.size.height + return hasArea && isHorizontallyInside && isVerticallyInside +} + +/** + * Обычный (не Snapshot-state) изменяемый холдер — для значений, которые должны переживать + * рекомпозицию, но не обязаны сами её вызывать при записи. + */ +private class MutableRef(var value: T) diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleTriggerIcon.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleTriggerIcon.kt new file mode 100644 index 0000000000..7a1f096f64 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/components/bubble/BubbleTriggerIcon.kt @@ -0,0 +1,119 @@ +package com.sdds.plasma.homeds.components.bubble + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.sdds.compose.uikit.Icon +import com.sdds.compose.uikit.resourceImageSource +import com.sdds.compose.uikit.shadow.ShadowLayer + +/** + * Анимированное содержимое триггера — кроссфейд + скейл между звёздочкой (свёрнутое состояние) + * и крестиком (развёрнутое состояние), плюс декоративная рамка-«мерцание» вокруг них, которая + * поворачивается при морфинге. + * + * @param morphProgress прогресс морфинга 0f..1f (альфа/масштаб иконок, альфа фона-подложки) + * @param rotationProgress прогресс поворота обводки 0f..1f, на overshoot-кривой — может кратковременно + * выходить за границы 0f..1f + * @param sizeScale масштаб рамки-обводки+фона относительно [borderSize] + * @param tint кисть иконок + * @param starIcon иконка свёрнутого (морфинг=0) состояния, задаётся стилем (см. [BubbleStyle.starIcon]) + * @param closeIcon иконка развёрнутого (морфинг=1) состояния (см. [BubbleStyle.closeIcon]) + * @param backgroundBrush кисть заливки-подложки внутри обводки + * @param borderBrush кисть обводки-рамки + * @param borderSize базовый размер рамки-обводки до применения [sizeScale] + * @param glowLayer параметры собственного внутреннего glow этого слоя, `null` пропускает отрисовку + * @param modifier модификатор, задаёт размер самих иконок (звёздочка/крестик) + */ +@Composable +internal fun BubbleTriggerIcon( + morphProgress: () -> Float, + rotationProgress: () -> Float, + sizeScale: () -> Float, + tint: Brush, + @DrawableRes starIcon: Int, + @DrawableRes closeIcon: Int, + backgroundBrush: Brush, + borderBrush: Brush, + borderSize: Dp, + glowLayer: ShadowLayer?, + modifier: Modifier = Modifier, +) { + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(borderSize) + .graphicsLayer { + rotationZ = BORDER_ROTATION_DEGREES * rotationProgress() + alpha = 1f - morphProgress() + scaleX = sizeScale() + scaleY = sizeScale() + } + .drawWithCache { + val outline = BubbleStarFourShape.createOutline(size, layoutDirection, this) + val path = (outline as Outline.Generic).path + val glowRadiusPx = glowLayer?.blurRadius?.toPx() ?: 0f + val glowPainter = createBubbleInnerGlowPainter(size) + onDrawBehind { + drawPath(path, backgroundBrush) + if (glowLayer != null) { + with(glowPainter) { draw(path, glowLayer.color, glowRadiusPx) } + } + } + }, + ) + Box( + modifier = Modifier + .size(borderSize) + .graphicsLayer { + rotationZ = BORDER_ROTATION_DEGREES * rotationProgress() + scaleX = sizeScale() + scaleY = sizeScale() + } + .border(BorderStroke(BADGE_BORDER_WIDTH, borderBrush), BubbleStarFourShape), + ) + Icon( + source = resourceImageSource(starIcon), + brush = { tint }, + contentDescription = null, + modifier = modifier.graphicsLayer { + alpha = 1f - morphProgress() + val scale = 1f - morphProgress() * STAR_SHRINK_FACTOR + scaleX = scale + scaleY = scale + }, + ) + Icon( + source = resourceImageSource(closeIcon), + brush = { tint }, + contentDescription = null, + modifier = modifier.graphicsLayer { + alpha = morphProgress() + val scale = CLOSE_START_SCALE + morphProgress() * (1f - CLOSE_START_SCALE) + scaleX = scale + scaleY = scale + }, + ) + } +} + +/** + * Доля от диаметра круга-якоря, которую занимает рамка-обводка триггера на углу развёрнутого тела. + */ +internal const val BUBBLE_BADGE_BORDER_SCALE = 26f / 32f + +private val BADGE_BORDER_WIDTH = 1.dp +private const val STAR_SHRINK_FACTOR = 0.3f +private const val CLOSE_START_SCALE = 0.7f +private const val BORDER_ROTATION_DEGREES = 45f diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/motion/bubble/BubbleMotion.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/motion/bubble/BubbleMotion.kt new file mode 100644 index 0000000000..5db06e0551 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/motion/bubble/BubbleMotion.kt @@ -0,0 +1,22 @@ +package com.sdds.plasma.homeds.motion.bubble + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import com.sdds.compose.uikit.motion.Motion +import com.sdds.compose.uikit.motion.MotionContext +import com.sdds.compose.uikit.motion.rememberMotion +import com.sdds.compose.uikit.motion.rememberMotionContext + +/** + * Создаёт [Motion] для апсейл-бабла. + * @param style стиль анимации interaction-состояний + * @param motionContext контекст анимации + */ +@Composable +@NonRestartableComposable +public fun rememberBubbleMotion( + style: BubbleMotionStyle = LocalBubbleMotionStyle.current, + motionContext: MotionContext = rememberMotionContext(), +): Motion { + return rememberMotion(style, motionContext) +} diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/motion/bubble/BubbleMotionStyle.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/motion/bubble/BubbleMotionStyle.kt new file mode 100644 index 0000000000..e934c1fa34 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/motion/bubble/BubbleMotionStyle.kt @@ -0,0 +1,298 @@ +package com.sdds.plasma.homeds.motion.bubble + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.Dp +import com.sdds.compose.uikit.motion.MotionProperty +import com.sdds.compose.uikit.motion.MotionStyle +import com.sdds.compose.uikit.motion.MotionStyleBuilder +import com.sdds.compose.uikit.motion.noMotion + +/** + * CompositionLocal, предоставляющий текущий [BubbleMotionStyle]. + */ +public val LocalBubbleMotionStyle = compositionLocalOf { BubbleMotionStyle.builder().style() } + +/** + * Анимационные (interaction-state) свойства для [BubbleTrigger]/[BubbleHost]. + * + * Не имеет отношения к таймлайну controlled-разворота/сворачивания бабла — он реализован + * отдельным `Animatable`-кодом внутри компонента, а не через токен-driven Motion-систему. + * Здесь описываются только interaction-driven переходы (например, при нажатии). + * + * Все свойства по умолчанию — [noMotion] (мгновенный снап, без анимации): `Bubble.Default` пока не + * задаёт ни одного interactive-варианта, поэтому анимировать нечего — но сами свойства уже готовы + * принять реальный transition-спек, когда появится дизайн-спека на interactive-состояния. + */ +@Stable +public interface BubbleMotionStyle : MotionStyle { + + /** + * Анимационное свойство цвета тела бабла/свёрнутого триггера. + */ + public val bodyColor: MotionProperty + + /** + * Анимационное свойство цвета иконки триггера. + */ + public val iconColor: MotionProperty + + /** + * Анимационное свойство цвета декоративной обводки-«мерцания». + */ + public val borderColor: MotionProperty + + /** + * Анимационное свойство цвета текста содержимого развёрнутого бабла. + */ + public val textColor: MotionProperty + + /** + * Анимационное свойство диаметра круга-якоря. + */ + public val circleSize: MotionProperty + + /** + * Анимационное свойство размера иконки триггера. + */ + public val iconSize: MotionProperty + + /** + * Анимационное свойство радиуса скругления «дальних» углов тела. + */ + public val bodyCornerRadius: MotionProperty + + /** + * Анимационное свойство радиуса скругления ближнего к кругу-якорю угла тела. + */ + public val bodyNearCornerRadius: MotionProperty + + /** + * Анимационное свойство минимальной высоты тела. + */ + public val bodyMinHeight: MotionProperty + + /** + * Анимационное свойство ширины шейки. + */ + public val neckWidth: MotionProperty + + /** + * Анимационное свойство длины шейки. + */ + public val neckLength: MotionProperty + + /** + * Анимационное свойство радиуса галтели шейки. + */ + public val neckFilletRadius: MotionProperty + + /** + * Анимационное свойство внутреннего отступа содержимого от начального края. + */ + public val paddingStart: MotionProperty + + /** + * Анимационное свойство внутреннего отступа содержимого от конечного края. + */ + public val paddingEnd: MotionProperty + + /** + * Анимационное свойство внутреннего отступа содержимого от верхнего края. + */ + public val paddingTop: MotionProperty + + /** + * Анимационное свойство внутреннего отступа содержимого от нижнего края. + */ + public val paddingBottom: MotionProperty + + public companion object { + + /** + * Создаёт билдер для построения [BubbleMotionStyle]. + */ + public fun builder(): BubbleMotionStyleBuilder = BubbleMotionStyleImpl.Builder() + } +} + +/** + * Билдер для поэтапной конфигурации [BubbleMotionStyle]. + */ +@Stable +public interface BubbleMotionStyleBuilder : MotionStyleBuilder { + + /** @see BubbleMotionStyle.bodyColor */ + public fun bodyColor(color: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.iconColor */ + public fun iconColor(color: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.borderColor */ + public fun borderColor(color: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.textColor */ + public fun textColor(color: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.circleSize */ + public fun circleSize(size: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.iconSize */ + public fun iconSize(size: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.bodyCornerRadius */ + public fun bodyCornerRadius(radius: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.bodyNearCornerRadius */ + public fun bodyNearCornerRadius(radius: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.bodyMinHeight */ + public fun bodyMinHeight(height: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.neckWidth */ + public fun neckWidth(width: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.neckLength */ + public fun neckLength(length: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.neckFilletRadius */ + public fun neckFilletRadius(radius: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.paddingStart */ + public fun paddingStart(padding: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.paddingEnd */ + public fun paddingEnd(padding: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.paddingTop */ + public fun paddingTop(padding: MotionProperty): BubbleMotionStyleBuilder + + /** @see BubbleMotionStyle.paddingBottom */ + public fun paddingBottom(padding: MotionProperty): BubbleMotionStyleBuilder +} + +@Immutable +private class BubbleMotionStyleImpl( + override val bodyColor: MotionProperty, + override val iconColor: MotionProperty, + override val borderColor: MotionProperty, + override val textColor: MotionProperty, + override val circleSize: MotionProperty, + override val iconSize: MotionProperty, + override val bodyCornerRadius: MotionProperty, + override val bodyNearCornerRadius: MotionProperty, + override val bodyMinHeight: MotionProperty, + override val neckWidth: MotionProperty, + override val neckLength: MotionProperty, + override val neckFilletRadius: MotionProperty, + override val paddingStart: MotionProperty, + override val paddingEnd: MotionProperty, + override val paddingTop: MotionProperty, + override val paddingBottom: MotionProperty, +) : BubbleMotionStyle { + + class Builder : BubbleMotionStyleBuilder { + private var bodyColor: MotionProperty? = null + private var iconColor: MotionProperty? = null + private var borderColor: MotionProperty? = null + private var textColor: MotionProperty? = null + private var circleSize: MotionProperty? = null + private var iconSize: MotionProperty? = null + private var bodyCornerRadius: MotionProperty? = null + private var bodyNearCornerRadius: MotionProperty? = null + private var bodyMinHeight: MotionProperty? = null + private var neckWidth: MotionProperty? = null + private var neckLength: MotionProperty? = null + private var neckFilletRadius: MotionProperty? = null + private var paddingStart: MotionProperty? = null + private var paddingEnd: MotionProperty? = null + private var paddingTop: MotionProperty? = null + private var paddingBottom: MotionProperty? = null + + override fun bodyColor(color: MotionProperty): BubbleMotionStyleBuilder = apply { + this.bodyColor = color + } + + override fun iconColor(color: MotionProperty): BubbleMotionStyleBuilder = apply { + this.iconColor = color + } + + override fun borderColor(color: MotionProperty): BubbleMotionStyleBuilder = apply { + this.borderColor = color + } + + override fun textColor(color: MotionProperty): BubbleMotionStyleBuilder = apply { + this.textColor = color + } + + override fun circleSize(size: MotionProperty): BubbleMotionStyleBuilder = apply { + this.circleSize = size + } + + override fun iconSize(size: MotionProperty): BubbleMotionStyleBuilder = apply { + this.iconSize = size + } + + override fun bodyCornerRadius(radius: MotionProperty): BubbleMotionStyleBuilder = apply { + this.bodyCornerRadius = radius + } + + override fun bodyNearCornerRadius(radius: MotionProperty): BubbleMotionStyleBuilder = apply { + this.bodyNearCornerRadius = radius + } + + override fun bodyMinHeight(height: MotionProperty): BubbleMotionStyleBuilder = apply { + this.bodyMinHeight = height + } + + override fun neckWidth(width: MotionProperty): BubbleMotionStyleBuilder = apply { + this.neckWidth = width + } + + override fun neckLength(length: MotionProperty): BubbleMotionStyleBuilder = apply { + this.neckLength = length + } + + override fun neckFilletRadius(radius: MotionProperty): BubbleMotionStyleBuilder = apply { + this.neckFilletRadius = radius + } + + override fun paddingStart(padding: MotionProperty): BubbleMotionStyleBuilder = apply { + this.paddingStart = padding + } + + override fun paddingEnd(padding: MotionProperty): BubbleMotionStyleBuilder = apply { + this.paddingEnd = padding + } + + override fun paddingTop(padding: MotionProperty): BubbleMotionStyleBuilder = apply { + this.paddingTop = padding + } + + override fun paddingBottom(padding: MotionProperty): BubbleMotionStyleBuilder = apply { + this.paddingBottom = padding + } + + @Suppress("CyclomaticComplexMethod") + override fun style(): BubbleMotionStyle = BubbleMotionStyleImpl( + bodyColor = bodyColor ?: noMotion(), + iconColor = iconColor ?: noMotion(), + borderColor = borderColor ?: noMotion(), + textColor = textColor ?: noMotion(), + circleSize = circleSize ?: noMotion(), + iconSize = iconSize ?: noMotion(), + bodyCornerRadius = bodyCornerRadius ?: noMotion(), + bodyNearCornerRadius = bodyNearCornerRadius ?: noMotion(), + bodyMinHeight = bodyMinHeight ?: noMotion(), + neckWidth = neckWidth ?: noMotion(), + neckLength = neckLength ?: noMotion(), + neckFilletRadius = neckFilletRadius ?: noMotion(), + paddingStart = paddingStart ?: noMotion(), + paddingEnd = paddingEnd ?: noMotion(), + paddingTop = paddingTop ?: noMotion(), + paddingBottom = paddingBottom ?: noMotion(), + ) + } +} diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/styles/customcomponents/bubble/Bubble.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/styles/customcomponents/bubble/Bubble.kt new file mode 100644 index 0000000000..c95a7c3de2 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/styles/customcomponents/bubble/Bubble.kt @@ -0,0 +1,6 @@ +package com.sdds.plasma.homeds.styles.customcomponents.bubble + +/** + * Вспомогательный объект для описания API и стиля компонента Bubble + */ +public object Bubble diff --git a/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/styles/customcomponents/bubble/BubbleStyles.kt b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/styles/customcomponents/bubble/BubbleStyles.kt new file mode 100644 index 0000000000..9a00336f6e --- /dev/null +++ b/tokens/plasma.homeds.compose/src/main/kotlin/com/sdds/plasma/homeds/styles/customcomponents/bubble/BubbleStyles.kt @@ -0,0 +1,85 @@ + +@file:Suppress( + "UndocumentedPublicClass", + "UndocumentedPublicProperty", +) + +package com.sdds.plasma.homeds.styles.customcomponents.bubble + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.sdds.compose.uikit.shadow.ShadowAppearance +import com.sdds.compose.uikit.shadow.ShadowLayer +import com.sdds.compose.uikit.style.BuilderWrapper +import com.sdds.compose.uikit.style.wrap +import com.sdds.icons.R +import com.sdds.plasma.homeds.components.bubble.BubbleStyle +import com.sdds.plasma.homeds.components.bubble.BubbleStyleBuilder +import com.sdds.plasma.homeds.theme.PlasmaHomeDsTheme + +/** + * Базовый интерфейс для всех оберток этого стиля + */ +public interface WrapperBubble : BuilderWrapper + +/** + * Обёртка для вариации Default. + */ +@JvmInline +public value class WrapperBubbleDefault( + public override val builder: BubbleStyleBuilder, +) : WrapperBubble + +public val Bubble.Default: WrapperBubbleDefault + @Composable + @JvmName("WrapperBubbleDefault") + get() = BubbleStyle.builder() + .colors { + bodyColor(PlasmaHomeDsTheme.colors.surfaceDefaultPositive) + iconColor(PlasmaHomeDsTheme.colors.textOnDarkPrimary) + borderColor(PlasmaHomeDsTheme.colors.outlineOnDarkTransparentPrimary) + textColor(PlasmaHomeDsTheme.colors.textOnDarkPrimary) + progressTrackColor(PlasmaHomeDsTheme.colors.textDefaultTertiary) + progressValueColor(Color(0xF5D0FF77)) + } + .dimensions { + circleSize(32.dp) + iconSize(16.dp) + bodyCornerRadius(12.dp) + bodyNearCornerRadius(5.dp) + bodyMinHeight(40.dp) + width(74.dp) + neckWidth(12.dp) + neckLength(3.dp) + neckFilletRadius(3.dp) + paddingStart(8.dp) + paddingEnd(8.dp) + paddingTop(6.dp) + paddingBottom(8.dp) + progressHeight(2.dp) + progressSpacing(8.dp) + progressPointerThickness(2.dp) + progressCornerRadius(1.dp) + chevronTextOverlap(3.dp) + chevronIconSize(16.dp) + } + .textStyle(PlasmaHomeDsTheme.typography.bodyXsNormal) + .shadow( + ShadowAppearance( + listOf( + ShadowLayer( + color = Color(0xFFD0FF77).copy(alpha = 0.6f), + offset = DpOffset.Zero, + spreadRadius = 0.dp, + blurRadius = 4.dp, + ), + ), + ), + ) + .starIcon(R.drawable.ic_star_four_fill_16) + .closeIcon(R.drawable.ic_close_24) + .expandDuration(600) + .collapseDuration(600) + .wrap(::WrapperBubbleDefault) diff --git a/tokens/plasma.homeds.compose/src/test/kotlin/com/sdds/plasma/homeds/ComposeBubbleBehaviorTest.kt b/tokens/plasma.homeds.compose/src/test/kotlin/com/sdds/plasma/homeds/ComposeBubbleBehaviorTest.kt new file mode 100644 index 0000000000..6df56ea25c --- /dev/null +++ b/tokens/plasma.homeds.compose/src/test/kotlin/com/sdds/plasma/homeds/ComposeBubbleBehaviorTest.kt @@ -0,0 +1,187 @@ +package com.sdds.plasma.homeds + +import android.app.Application +import android.content.pm.ActivityInfo +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.sdds.compose.uikit.Text +import com.sdds.compose.uikit.fixtures.SDK_NUMBER +import com.sdds.compose.uikit.style.style +import com.sdds.plasma.homeds.components.bubble.BubbleHost +import com.sdds.plasma.homeds.components.bubble.BubbleTrigger +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Bubble +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Default +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [SDK_NUMBER], qualifiers = RobolectricDeviceQualifiers.Pixel6) +class ComposeBubbleBehaviorTest { + + @get:Rule(order = 0) + val addActivityToRobolectricRule = object : TestWatcher() { + override fun starting(description: Description?) { + super.starting(description) + val appContext: Application = ApplicationProvider.getApplicationContext() + val activityInfo = ActivityInfo().apply { + name = ComponentActivity::class.java.name + packageName = appContext.packageName + } + shadowOf(appContext.packageManager).addOrUpdateActivity(activityInfo) + } + } + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Test + fun triggerClickDoesNotChangeExpandedState() { + var triggerClickCount = 0 + composeTestRule.content { + BubbleHost(modifier = Modifier.size(100.dp)) { + BubbleTrigger( + onExpandedClick = {}, + expanded = false, + onTriggerClick = { triggerClickCount++ }, + contentDescription = TRIGGER_DESCRIPTION, + style = Bubble.Default.style(), + ) { + Text(text = BODY_TEXT) + } + } + } + + composeTestRule.onNodeWithContentDescription(TRIGGER_DESCRIPTION).performClick() + composeTestRule.waitForIdle() + + assertEquals(1, triggerClickCount) + composeTestRule.onNodeWithText(BODY_TEXT).assertDoesNotExist() + } + + @Test + fun multipleExpandedTriggersAreDisplayedIndependently() { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.content { + BubbleHost(modifier = Modifier.size(240.dp)) { + Row { + BubbleTrigger( + onExpandedClick = {}, + expanded = true, + style = Bubble.Default.style(), + ) { + Text(text = FIRST_BODY_TEXT) + } + BubbleTrigger( + onExpandedClick = {}, + expanded = true, + style = Bubble.Default.style(), + ) { + Text(text = SECOND_BODY_TEXT) + } + } + } + } + + composeTestRule.mainClock.advanceTimeBy(ANIMATION_SETTLE_MS) + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText(FIRST_BODY_TEXT).assertExists() + composeTestRule.onNodeWithText(SECOND_BODY_TEXT).assertExists() + } + + @Test + fun visibilityIsDistinctAndBecomesFalseWhenTriggerIsRemoved() { + val offset = mutableStateOf(0.dp) + val show = mutableStateOf(true) + val visibilityEvents = mutableListOf() + composeTestRule.content { + BubbleHost(modifier = Modifier.size(100.dp)) { + Box(modifier = Modifier.size(100.dp)) { + if (show.value) { + BubbleTrigger( + onExpandedClick = {}, + expanded = false, + onTriggerVisibilityChange = visibilityEvents::add, + style = Bubble.Default.style(), + modifier = Modifier.offset(x = offset.value), + ) { + Text(text = BODY_TEXT) + } + } + } + } + } + + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { offset.value = 10.dp } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { offset.value = 80.dp } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { offset.value = 60.dp } + composeTestRule.waitForIdle() + composeTestRule.runOnIdle { show.value = false } + composeTestRule.waitForIdle() + + assertEquals(listOf(true, false, true, false), visibilityEvents) + } + + @Test + fun visibleTriggersReportIndependently() { + val firstEvents = mutableListOf() + val secondEvents = mutableListOf() + composeTestRule.content { + BubbleHost(modifier = Modifier.size(100.dp)) { + Row { + BubbleTrigger( + onExpandedClick = {}, + expanded = false, + onTriggerVisibilityChange = firstEvents::add, + style = Bubble.Default.style(), + ) { + Text(text = FIRST_BODY_TEXT) + } + BubbleTrigger( + onExpandedClick = {}, + expanded = false, + onTriggerVisibilityChange = secondEvents::add, + style = Bubble.Default.style(), + ) { + Text(text = SECOND_BODY_TEXT) + } + } + } + } + + composeTestRule.waitForIdle() + + assertEquals(listOf(true), firstEvents) + assertEquals(listOf(true), secondEvents) + } +} + +private const val TRIGGER_DESCRIPTION = "Bubble trigger" +private const val BODY_TEXT = "Bubble body" +private const val FIRST_BODY_TEXT = "First bubble body" +private const val SECOND_BODY_TEXT = "Second bubble body" +private const val ANIMATION_SETTLE_MS = 700L diff --git a/tokens/plasma.homeds.compose/src/test/kotlin/com/sdds/plasma/homeds/ComposeBubbleScreenshotTest.kt b/tokens/plasma.homeds.compose/src/test/kotlin/com/sdds/plasma/homeds/ComposeBubbleScreenshotTest.kt new file mode 100644 index 0000000000..d32c579469 --- /dev/null +++ b/tokens/plasma.homeds.compose/src/test/kotlin/com/sdds/plasma/homeds/ComposeBubbleScreenshotTest.kt @@ -0,0 +1,130 @@ +package com.sdds.plasma.homeds + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.sdds.compose.uikit.Text +import com.sdds.compose.uikit.fixtures.RoborazziConfigCompose +import com.sdds.compose.uikit.fixtures.SDK_NUMBER +import com.sdds.compose.uikit.style.style +import com.sdds.plasma.homeds.components.bubble.BubbleAlignment +import com.sdds.plasma.homeds.components.bubble.BubbleHost +import com.sdds.plasma.homeds.components.bubble.BubblePlacement +import com.sdds.plasma.homeds.components.bubble.BubbleTrigger +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Bubble +import com.sdds.plasma.homeds.styles.customcomponents.bubble.Default +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Скриншот-тесты controlled-состояний [BubbleTrigger]: collapsed и expanded. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [SDK_NUMBER], qualifiers = RobolectricDeviceQualifiers.Pixel6) +class ComposeBubbleScreenshotTest( + theme: String, +) : RoborazziConfigCompose(theme) { + + @OptIn(ExperimentalRoborazziApi::class) + @Test + fun testCollapsedShowsStarIcon() { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.content { + BubbleHost { + Box(modifier = Modifier.padding(48.dp)) { + BubbleTrigger( + onExpandedClick = {}, + expanded = false, + style = Bubble.Default.style(), + ) { + Text(text = "Прокачать до 100%") + } + } + } + } + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.waitForIdle() + } + + @OptIn(ExperimentalRoborazziApi::class) + @Test + fun testExpandedShowsBubbleBody() { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.content { + BubbleHost { + Box(modifier = Modifier.padding(48.dp)) { + BubbleTrigger( + onExpandedClick = {}, + expanded = true, + style = Bubble.Default.style(), + contentDescription = TriggerContentDescription, + ) { + Text(text = "Прокачать до 100%") + } + } + } + } + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(EXPAND_ANIMATION_SETTLE_MS) + composeTestRule.waitForIdle() + } + + @OptIn(ExperimentalRoborazziApi::class) + @Test + fun testExpandedBubbleOverlapsNarrowNeighborCards() { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.content { + BubbleHost { + Row( + modifier = Modifier.padding(48.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(NEIGHBOR_CARD_COUNT) { index -> + Box( + modifier = Modifier + .size(narrowCardSize) + .background(Color.DarkGray), + ) { + if (index == 0) { + BubbleTrigger( + onExpandedClick = {}, + expanded = true, + placement = BubblePlacement.Top, + alignment = BubbleAlignment.End, + style = Bubble.Default.style(), + contentDescription = TriggerContentDescription, + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + ) { + Text(text = "Прокачать до 100%") + } + } + } + } + } + } + } + composeTestRule.mainClock.advanceTimeByFrame() + composeTestRule.waitForIdle() + composeTestRule.mainClock.advanceTimeBy(EXPAND_ANIMATION_SETTLE_MS) + composeTestRule.waitForIdle() + } +} + +private const val EXPAND_ANIMATION_SETTLE_MS = 700L +private const val TriggerContentDescription = "Апсейл" +private const val NEIGHBOR_CARD_COUNT = 3 +private val narrowCardSize = 90.dp