presentation/widgets/common/common_widgets library

Barrel export for the "common widgets" kit — a set of generic, game-agnostic candy-styled widgets any project built on this base can import in one line:

import 'package:roy_casual_kit/presentation/widgets/common/common_widgets.dart';

Classes

AchievementUnlockListener
Wraps child and shows a ToastBanner every time AchievementService.onUnlock fires (IDEA-43) — the ready-to-use on-screen counterpart to AchievementService, which the rest of the kit had no widget consuming at all before this.
AdaptiveGameHud
Places HUD content (HudSlot.topStart/topCenter/topEnd/bottom/ side/overlay) so a game doesn't have to hand-roll safe-area, notch, keyboard-inset, text-scale, RTL, and orientation handling itself every time it adds a HUD element (FEAT-52).
AppVersionGateOverlay
Wraps child with a blocking/dismissible overlay driven by decision — takes plain data (same "widgets take data, caller owns the service" convention as EnergyBar/LevelSelectGrid), never reads AppVersionGateController itself.
AsyncCommonButton
A CommonButton wrapper that drives itself through AsyncButtonStatus.loading/success/error around an async onPressed callback — the state machine BackupRestorePanel and similar hand-rolled per-widget before this existed (FEAT-50). Ignores a tap while not AsyncButtonStatus.idle (a rapid double-tap can't start a second onPressed call, and a tap during the success/error cooldown is ignored too, not queued).
AvatarFrame
Circular avatar wrapping an arbitrary child (typically an Image, Icon, or initials Text) with a decorative colored ring around it — for player profile displays.
BackupRestorePanel
UI for the save-backup flow StorageService.exportAll()/importAll() and save_integrity.dart's HMAC sign/verify already support at the core layer but have no widget to drive them from.
BadgeDot
Small round dot signaling "something's new"/"unclaimed reward" — placed inside a Stack wrapping the caller's own icon/widget (e.g. Stack(children: [Icon(...), Positioned( top: -2, right: -2, child: BadgeDot())])). Does not position itself.
BottomSheetPanel
Candy-styled panel for the bottom of a sheet: rounded top corners, a drag handle bar, NeonTheme.card background. Pure visual container — pair it with showCommonBottomSheet to actually present it.
CandyTextField
Candy-styled text input — NeonTheme.card background, rounded border, glow when focused — for player-name entry, redeem codes, feedback forms, etc. Fills the "Buttons & Interactive" category's one remaining gap (every other control there has a candy-styled equivalent; text entry previously had none, forcing a bare Material TextField).
CandyToggleSwitch
A candy-styled on/off switch: pill track + a round thumb that slides left/right, replacing Material's default Switch/SwitchListTile when visual style needs to match the candy widget kit.
CircularProgressRing
Ring-shaped progress indicator (custom-painted, not the default CircularProgressIndicator) — used for a countdown timer / daily quest ring, with a label/icon centered inside it. Animates the arc when progress changes via TweenAnimationBuilder.
CoinFlyOverlay
N icon "coins" fly from a source point to a target's current on-screen position — read from a GlobalKey via RenderBox.localToGlobal, the same technique used elsewhere in Flutter for this — staggered slightly so they read as individual coins instead of one blob. FEAT-12's "coin fly to CurrencyCounter" reward juice.
ComboHeatBackground
Reactive background that lerps between a "cool" and "hot" color as heat rises (IDEA-08) — the caller computes heat from whatever combo/ streak mechanic it has (e.g. min(1.0, comboCount / 10)); this widget has no idea what a "combo" is, matching VictoryCardTemplate's convention of taking caller-computed values rather than inventing game-specific mechanics.
CommonButton
Shared button widget, with more variants than NeonButton (NeonButton stays as-is, this doesn't replace it). primary/secondary/danger are rounded pills with a label (secondary = outline/light background instead of a solid gradient, danger = red tone); icon is a round icon-only button (settings/close/...).
CommonListTile
Styled list row for a settings row / leaderboard row / etc — candy-themed replacement for the default Material ListTile. When filled is true the row gets a NeonTheme.card background + rounded corners (a standalone "card row"); when false the background is transparent (for rows already inside a PanelCard or other container, e.g. a divided list).
ConfettiOverlay
Full-screen confetti burst — N colored paper pieces rain down from the top, sway, spin, and fade out; the effect stops and hides itself after duration (unlike NeonBg/AuroraBgLayer, which run forever). Drop it in a Stack over a win/level-complete screen; give it a fresh key each time you want to retrigger it (a new key mounts a fresh particle set and ticker instead of reusing a finished one).
ConfettiParticle
One confetti piece's fixed randomized traits, generated once at spawn. Motion at any time t is a pure function of these traits (see confettiOffsetAt/confettiRotationAt/confettiOpacityAt) so the simulation is deterministic and unit-testable without building a widget.
ConsentBanner
Wraps child and shows a one-time GDPR/CCPA-style consent banner (IDEA-69) before any ConsentCategory the app might gate analytics or personalization behind is ever decided — ConsentStateService has the state (grant/deny/reset) and ConsentGatedAnalyticsProvider has the enforcement, but nothing in the kit actually asked the player until this widget.
CooldownCountdownChip
Adapter over CountdownChip for a keyed cooldown read from PersistentCooldownService — takes plain remaining data rather than reaching into the service directly (same convention as EnergyBar/ LevelSelectGrid: widgets take data, callers own the GetX service).
CountdownChip
Chip that live-counts down to target, formatted through the shared fmtDur (no reimplemented duration formatting). Ticks every second via an internal Timer.periodic, cancels it in dispose (and on reaching zero) so it never lingers past unmount, and calls onDone exactly once when the countdown reaches zero.
CurrencyCounter
Icon + a currency amount, smoothly counting up/down when value changes (instead of jumping instantly) — a generic replacement for the old CoinChip (removed because it was tightly bound to specific game state). Doesn't know what coins/gems are itself, it just displays a number.
DailyLoginCalendarWidget
Read-only 7-day (configurable via cycleLength) login-streak calendar — a pure display widget, it holds no streak state of its own. The caller reads DailyLoginService and hands in currentStreakDay/ claimedDaysInCycle/canClaimToday, matching this kit's convention (see LevelSelectGrid) of widgets taking plain data rather than reaching into a GetX service directly.
EmptyStatePlaceholder
Centered icon + message for "no data yet" states (empty leaderboard, no achievements unlocked, ...). Icon sits in a soft circular badge, mirroring the icon treatment in NeonDialog.panel (neon_dialog.dart).
EnergyBar
Row of maxEnergy heart pips (filled up to currentEnergy, dimmed beyond), plus a live-ticking mm:ss countdown to the next regen point — a pure display widget, it holds no energy state of its own. The caller reads EnergyService and hands in currentEnergy/maxEnergy/ timeUntilNextEnergy/hasInfiniteLives, matching this kit's convention (see LevelSelectGrid/DailyLoginCalendarWidget) of widgets taking plain data rather than reaching into a GetX service directly.
FloatingComboText
Combo/score text ("+10", "Combo x3") that floats up and fades out in place, then removes itself — the FEAT-12 coin-fly widget flies to a target, this one just pops where it appears (cheap but effective "game-feel" juice).
GameOverCardTemplate
Pre-built "game over" card — the loss/retry counterpart to PanelCard-based VictoryCardTemplate for a level-complete moment. Deliberately smaller: no avatar/QR/share wiring (a losing moment has no invite-link/share use case), just a headline, optional message and stat lines, plus one or two action buttons (typically "Retry" and "Home").
HoldToConfirmButton
A button that only fires onConfirm after being held down for duration — for a reset/delete/purchase action a stray tap shouldn't be able to trigger.
IconBadgeButton
Round icon button (e.g. settings/shop) that can carry a small badge in its corner — a plain notification dot (showBadge), or a count (badgeCount) when a number is needed.
InventoryGrid
A data-driven grid over an InventorySnapshot — like QuestViewModel/ LeaderboardEntry, this widget holds no game logic or catalog of its own; itemBuilder is the caller's own renderer (item icon, rarity frame, stack count, equipped badge — this package doesn't ship a concrete item catalog, see ItemDefinition's doc), and a tap never mutates anything here — the caller's own tap handler is expected to call InventoryService.consume/setEquipped/moveSlot itself.
LeaderboardEntry
One caller-supplied leaderboard row — rank, name and score are already formatted/localized strings (same convention as VictoryCardTemplate's statLines: this widget never invents ranking or number formatting).
LeaderboardList
Pure, data-driven leaderboard display (rank + name + score + optional avatar), candy-styled to match the rest of common/. Composes AvatarFrame for the avatar slot; ranking/scoring logic and data all come from the consumer app.
LevelNodeButton
One round level node — the world-map/level-select building block. Shows the level number, or a lock glyph (and blocks tap) when state is locked, plus a mini StarRating badge underneath once completed.
LevelSelectGrid
Read-only grid of LevelNodeButtons for a world-map/level-select screen — a pure display widget, it holds no progress state itself. states gives each level's LevelState in order (level 1 at index 0); starsEarnedByLevel optionally maps a 1-based level number to its earned star count. onLevelTap fires with the 1-based level number of whichever unlocked/completed node was tapped — locked nodes never fire it.
LevelUpCelebration
One LevelUpEvent plus the extra presentation detail LevelUpOverlayController needs that LevelUpEvent itself doesn't carry: where its XP bar starts filling FROM. Every level after the first in a queue starts a fresh bar at 0 — only the very first queued event might start partway through (wherever the player's bar actually was right before the grantXp call that crossed it).
LevelUpOverlay
Renders LevelUpOverlayController's sequence over child — same in-tree-overlay pattern as NeonDialog/SceneTransitionOverlay (see CLAUDE.md's "Dialog pattern" note), so it also works over a full-screen Flame GameWidget.
LevelUpOverlayController
Pure state machine driving LevelUpOverlay — no Animation/ BuildContext here, so it's unit-testable without pumping a widget tree (same shape as SceneTransitionController, FEAT-58).
LoadingOverlay
Full-screen dimmed barrier + centered candy-styled spinner.
NetworkStatusBanner
Thin banner pinned to the top of the screen, shown while connected is false (e.g. so a silent cloud-save failure isn't a confusing "why didn't my progress save?" moment).
PaginatedDotsIndicator
N dots for a paginated carousel/onboarding flow — the dot at currentIndex renders bigger/brighter than the rest. Pure presentation: does not own a PageController itself, the caller wires it up via PageView.onPageChanged and passes the resulting index in.
PanelCard
Generic rounded card/panel — the building-block container most screens wrap content in. Background is NeonTheme.card (or NeonTheme.cardAlt via alt), with a NeonTheme.drop shadow and an optional colored borderColor accent ring.
PauseOverlay
Pause panel wired directly to GameSessionController — the pause button/system-lifecycle-pause round trip this repo already has (see game_session_controller.dart's own RoyLifecycleCoordinator hook) gets exactly one visible UI on top of it. Caller mounts this unconditionally inside their own Stack (same "always mounted, NeonDialog.overlaySlot animates the panel in/out" convention as every other in-tree overlay in this kit) — it decides on its own, from session, whether to show:
ProgressBarStars
Rounded pill-shaped progress bar with 1-3 star markers along the track — a marker the fill has passed lights up (filled), otherwise it stays a dim outline. Used for world progress / event tracks like "reach 33%/66%/100% to unlock a star".
QuestBoardPanel
Displays a list of QuestViewModels — progress bar + a Claim button that's only enabled once a quest is completed and unclaimed — the ready-to-use on-screen counterpart to DailyQuestService (IDEA-29), which previously had no widget consuming it at all.
QuestViewModel
One caller-supplied quest row — label/progress/target/claimed are already resolved by the caller (typically read straight off DailyQuestService.progressOf/targetOf/isClaimed for id); this widget never talks to that service directly, same "pure/data-driven, caller owns the source of truth" convention as LeaderboardEntry.
ReactiveEnergyBar
Service-bound freshness wrapper for EnergyBar (BUG-95).
RetryErrorState
Data-driven "this failed, here's why, try again" panel — composed from EmptyStatePlaceholder (same icon-badge/title/message chrome every other empty/error state in this kit already uses) plus an optional AsyncCommonButton retry action, which is what actually gives "rapid tap doesn't run the retry twice" for free (see async_common_button.dart's own re-entry guard) — this widget adds no tap-guarding of its own.
ReviewPromptTrigger
Wraps child and calls maybeRequestReview every time winStreakEvents fires (ENH-87) — the ready-to-use widget counterpart to in_app_review_helper.dart's decision logic, which the rest of the kit had no widget consuming at all before this. Same "core service has timing logic → widget listener wired to it" pattern as AchievementUnlockListener/AchievementService.
RewardChoiceOption
One choosable reward option in a RewardChoicePanel. Immutable — the panel never mutates these, only tracks which ids are selected/claimed separately.
RewardChoicePanel
Lets a player pick one (or, with multiSelect, several) reward from options, then confirm — this panel only ever hands back the chosen ids via onConfirm; actually granting the reward is the caller's job (typically a RewardTransactionPipeline.grant() call keyed off those ids), matching this repo's "widgets don't self-grant" convention (see CLAUDE.md).
RewardPopup
Celebration popup (level-up, reward unlocked, ...) — a panel layout like NeonDialog.panel (rounded NeonTheme.card card, colored border, glow+drop shadow, title/message) but a standalone widget that doesn't route through NeonDialog, since it needs the confetti burst effect radiating around the panel, which NeonDialog doesn't support. content is a free slot — the caller drops in a StarRating/CurrencyCounter/...
RibbonBadge
Diagonal corner-ribbon overlay (e.g. "NEW"/"SALE"/"BEST VALUE") wrapping any child — shop/IAP item badge. Draws into its own internal Stack sized to child, independent of any Stack the caller might have (avoids the caller-owned-Stack coupling problem noted for LoadingOverlay/ENH-03 — just wrap: RibbonBadge(text: 'SALE', child: myCard)).
SaveHealthCard
A small self-contained diagnostic card (IDEA-59) for "is the player's save currently healthy" — the question support/QA gets asked after a "I lost my progress" report, without needing a dev to pull device logs.
SaveHealthCardState
SceneTransitionController
Pure state machine driving SceneTransitionOverlay — no Animation/ BuildContext here, so it's unit-testable without pumping a widget tree.
SceneTransitionOverlay
Fades a full-screen barrier over child while a controller-driven transition runs, showing progress while loading or RetryErrorState on failure — see CLAUDE.md's "Dialog pattern" note for why this is an in-tree overlay (works over a full-screen Flame GameWidget) rather than a pushed route, same reasoning as NeonDialog.
ScreenShake
Wraps child in a Transform.translate driven by controller. Ticks a Ticker only while a shake is actually decaying (stops once controller.offsetAt(...) returns Offset.zero, not left running forever). Respects NeonTheme.reducedMotion — when on, renders child unchanged regardless of what controller does, same as the decorative shader layers (ShaderTickerLayerState).
ScreenShakeController
Per-screen/per-widget-tree shake controller (IDEA-08) — a plain ChangeNotifier the caller creates and owns (like a TextEditingController), not a global GetxService singleton, since screen-shake state is local to whatever's shaking (a board, a card, a whole screen), not app-wide.
SectionHeader
Bold section title + optional trailing action (e.g. a "See all" button) — for grouping sections of a screen ("Daily Rewards", "Leaderboard", ...).
SegmentedTabBar
A 2-4 item pill tab bar (e.g. mode selection) — the active item is highlighted by an animated sliding background pill, the rest are plain text.
ShimmerPlaceholder
Skeleton-loading block for list/grid content that hasn't finished loading yet (a shop item row, a level list waiting on a cloud-save sync) — unlike LoadingOverlay (a full-screen barrier), this sits in-place as a stand-in for the real content.
ShopItemCard
Ready-made shop/IAP grid item: icon + title + price pill, composed from PanelCard + CommonButton (and optionally RibbonBadge) rather than every app re-assembling those by hand. Pure presentation — priceLabel is a caller-formatted string (e.g. "$0.99"), and onBuy is just a callback; this widget never calls into in_app_purchase itself (that's FEAT-01's job).
SoundToggleFab
Small floating round button toggling AudioManager's mute state — speaker icon flips on/off, tap calls toggleMute(). Uses AudioManager.maybe (the null-safe accessor): renders nothing if audio hasn't been registered instead of throwing, so a gameplay screen can drop this in unconditionally.
SpotlightHolePainter
Cuts a rounded-rect hole out of a full-screen dim scrim using Path.combine's difference op. Public (rather than the usual underscore-private painter convention in this file's siblings) so a widget test can pull .hole back off the mounted CustomPaint and assert it against the target's real RenderBox bounds.
SpotlightOverlay
Onboarding/tutorial coach-mark: dims the whole screen except a highlighted "hole" cut around a target widget's actual on-screen bounds, plus a guidance callout with a dismiss button.
SquashStretch
Non-uniform "squash" wrapper (IDEA-08) — unlike PressableScale's uniform scale-down, this squishes horizontally (scaleX up, scaleY down) on tap-down, then a real SpringSimulation (not a fixed Curve) bounces it back to (1.0, 1.0) on release, carrying over whatever velocity the spring already had (so a quick re-tap doesn't reset to a dead stop).
StarRating
A row of N stars (usually 3) — the first earned stars are lit/glowing, the rest are a dim outline. The classic "level complete, got 2/3 stars" look. animate = true makes each star pop in with a staggered delay after the previous one (used right after earning the reward); false draws it statically (already earned earlier, shown again without replaying the animation).
StreakCounter
Fire icon + a streak day count (consecutive logins, ...). Same layout/spacing as CurrencyCounter, but instead of counting up it pops (scale bounce) whenever days increases — a reward moment, same spirit as StarRating's pop-in.
ToastBanner
Small candy-styled toast banner + a static ToastBanner.show helper.
TooltipBubble
Small candy-styled speech-bubble container for coach-mark/hint use cases.
TutorialSequence
Orchestrates a multi-step onboarding tutorial by showing one SpotlightOverlay at a time for whatever step controller is currently on — composes SpotlightOverlay rather than reimplementing its highlight/callout logic; this widget is purely the step-advancing state machine around it.
TutorialSequenceController
Plain ChangeNotifier the caller creates and owns (like a TextEditingController, same pattern as ScreenShakeController in screen_shake.dart) — drives which step of a TutorialSequence is showing, if any.
TutorialStep
One step of a TutorialSequence — same fields as SpotlightOverlay (which this composes, not reimplements), minus SpotlightOverlay.onDismiss (the sequence supplies that itself, wired to advance to the next step).
VictoryCardTemplate
Pre-built "victory card" a game can drop straight in for a level-complete / result screen, then screenshot + share via the existing pipeline in share_helper.dart. This widget only renders — it has no idea a share pipeline exists.
WheelSegment
One slice of a WheelSpinner — caller-supplied, same convention as LeaderboardEntry/VictoryCardTemplate.statLines: this widget never invents reward values or odds, it only renders and animates.
WheelSpinner
A candy-styled "wheel of fortune" — draws its own pie-slice wheel via CustomPaint (no extra package dependency), spins to whatever index controller requests, and reports the landed WheelSegment via onSpinEnd. A fixed pointer marks the winning slice at the top; the wheel itself is the only thing that rotates.
WheelSpinnerController
Plain ChangeNotifier the caller creates and owns (like a TextEditingController, same pattern as ScreenShakeController in screen_shake.dart) — triggers a spin on the WheelSpinner it's attached to.
WheelSpinnerPainter
Public (rather than the usual underscore-private painter convention in this file's siblings) so a widget test can pull .segments back off the mounted CustomPaint — same testability reason as SpotlightOverlay's SpotlightHolePainter.

Enums

AsyncButtonStatus
AsyncCommonButton's current phase.
CommonButtonVariant
Button style: 3 labeled pill variants + 1 icon-only round variant.
ConfettiShape
Confetti piece shape — mixed randomly so a burst isn't visually uniform.
HoldToConfirmShape
Visual style of the fill-progress indicator while HoldToConfirmButton is being held. radial draws a ring around a circular icon button (the classic "hold to delete" shape); linear fills a pill from the left, like CommonButton's shape.
HudBreakpoint
Coarse layout mode AdaptiveGameHud switches between — compact hides HudSlot.side (no room for it), expanded shows every slot. Driven by width alone (see AdaptiveGameHud.compactBreakpointWidth) rather than orientation, since an orientation change on the same device already changes width — a separate orientation check would just be a second source of truth for the same decision.
HudSlot
Named regions AdaptiveGameHud arranges HUD content into (FEAT-52).
LevelState
Progress state of a single level node in a LevelSelectGrid — the grid holds no progress logic of its own, this is what the caller's own save/progress system hands in per level.
LevelUpPhase
Lifecycle of one queued level-up celebration: idle (no overlay) → xpFill (bar animates to full) → levelPop (level number bounces in) → rewardReveal (reward lines fade in) → back to idle once the whole queue is done, or xpFill again for the next queued level.
NotificationPermissionPrimerChoice
Which branch showNotificationPermissionPrimer resolved to.
SaveHealthResult
Outcome of SaveHealthCard's most recent self-check.
SceneTransitionPhase
Lifecycle of one scene transition: idle (no overlay, old/new scene interactive) → covering (fading a barrier in over the old scene) → loading (barrier up, showing progress) → revealing (fading the barrier back out over the new scene) → back to idle; or error instead of revealing if the load failed.
SmartReviewFunnelChoice
Which branch showSmartReviewFunnel resolved to.
TooltipPointerDirection
Which edge of the bubble the triangular pointer nub sits on — i.e. which side points at the thing being called out. up = nub on the top edge (bubble sits below its target), down = nub on the bottom edge (bubble sits above its target).

Functions

coinArcOffsetAt(Offset from, Offset to, double t, {required double arcHeight}) → Offset
Position along a quadratic Bézier arc from from to to at progress t (0..1) — IDEA-22, replaces a straight-line Offset.lerp so a coin visibly arcs up then down instead of sliding on a dead-straight line. The control point sits above the midpoint by arcHeight pixels. Pure function (no widget involved) so the curve shape is unit-testable on its own.
coinScaleAt(double t) → double
Scale at progress t (0..1): pops up to 1.2x over the first half of the flight, then eases down to a 0.9x "squash" by the time it lands — IDEA-22, replaces a constant 1.0 scale for the whole flight.
confettiOffsetAt(ConfettiParticle particle, double t) → Offset
Offset from its spawn point at t seconds since the burst started: falls straight down at ConfettiParticle.fallSpeed while swaying side-to-side on a sine wave. Pure function of (particle, t) — no widget/render state involved, so it's unit-testable on its own.
confettiOpacityAt(double t, double totalSeconds) → double
Opacity at t seconds into a totalSeconds effect: fully opaque until 70% elapsed, then fades linearly to 0 so the burst doesn't cut off abruptly. totalSeconds <= 0 is treated as already-finished (0).
confettiRotationAt(ConfettiParticle particle, double t) → double
Rotation (radians) at t seconds since the burst started.
generateConfettiParticles(int count, List<Color> colors, {Random? random}) → List<ConfettiParticle>
Generates count particles with randomized traits drawn from colors. Pure aside from random (pass a seeded Random for deterministic tests) — kept separate from the widget so "did we spawn the right number of particles, with colors from the given palette" is a plain unit test.
levelStateBorderColor(LevelState state) → Color
Node border color for state.
levelStateFillColor(LevelState state) → Color
Node fill color for state — dim gray for locked, card white for unlocked, gold for completed.
levelStateIcon(LevelState state) → IconData?
Icon shown over a LevelNodeButton for state — only locked gets one (a lock glyph instead of the level number); unlocked/completed just show the number, distinguished from each other by fill/border color.
levelStateTappable(LevelState state) → bool
Whether a node in state can be tapped at all.
showCommonBottomSheet<T>(BuildContext context, {required Widget child, Color? color, bool isDismissible = true, bool enableDrag = true, bool useRootNavigator = false, Color? barrierColor}) → Future<T?>
Presents child wrapped in a BottomSheetPanel via Flutter's native showModalBottomSheet.
showConfirmDialog(BuildContext context, {required String title, String? message, String? confirmLabel, String? cancelLabel, Color? color, IconData? icon}) → Future<bool>
Thin convenience wrapper over NeonDialog.show for the common "are you sure?" confirm/cancel case. Delegates all rendering to NeonDialog.show (which already handles the Flame-GameWidget-safe routing per this repo's Dialog pattern) — this just supplies the two actions and resolves to true/false based on which one was tapped.
showNotificationPermissionPrimer(BuildContext context, {required Future<void> onAccept(), Future<void> onDecline()?, String title = 'Bật thông báo để không bỏ lỡ phần thưởng?', String? message, String acceptLabel = 'Bật thông báo', String declineLabel = 'Để sau'}) → Future<NotificationPermissionPrimerChoice>
(IDEA-68) A soft "ask before you ask" primer shown BEFORE ReminderService's first scheduleNext()/cancel() call triggers the real native OS notification-permission dialog — same "soft-ask first" pattern showSmartReviewFunnel (IDEA-61) already uses for store reviews.
showSmartReviewFunnel(BuildContext context, {required Future<void> showReview(), Future<void> onFeedback(String feedback)?, String title = 'Bạn có thích game không?', String likeLabel = 'Thích ❤️', String dislikeLabel = 'Chưa thích 💔', String feedbackTitle = 'Điều gì khiến bạn chưa hài lòng?', String feedbackHint = 'Góp ý của bạn (không bắt buộc)', String feedbackSubmitLabel = 'Gửi góp ý'}) → Future<SmartReviewFunnelChoice>
(IDEA-61) A 2-step "Bạn có thích game không?" micro-survey shown BEFORE opening the real store review — routes a dissatisfied player to an internal feedback step instead of straight to a public store review, where a 1-star rating would otherwise be likely.

Typedefs

InventoryItemBuilder = Widget Function(BuildContext context, InventorySlot slot, bool isSelected)
Renders one filled cell — isSelected reflects InventoryGrid.selectedSlotId so the caller decides how to highlight it (this widget holds no selection state of its own, see class doc).
InventoryPlaceholderBuilder = Widget Function(BuildContext context, int cellIndex)
Renders one non-item cell (empty or locked) at cellIndex (0-based, stable across rebuilds as long as InventoryGrid.unlockedCapacity doesn't change).
SceneTransitionLoad = Future<SdkResult<void>> Function(void onProgress(double progress))
A transition's load step: does the real work (asset preload, screen setup, ...) and reports progress via onProgress (0.0–1.0) as it goes.