Skip to content

feat(ui): show scope snackbars above StreamScaffold's bottom chrome - #212

Open
xsahil03x wants to merge 15 commits into
mainfrom
feat/snackbar-placement
Open

xsahil03x wants to merge 15 commits into
mainfrom
feat/snackbar-placement

Conversation

@xsahil03x

@xsahil03x xsahil03x commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

What

  • A snackbar shown through StreamSnackbarScope now shows in each StreamScaffold under it, resting above its bottom instead of covering it. A scaffold nested in another under the same scope leaves it to the outer one. While the scope has no StreamScaffold under it, the snackbar shows at the scope's own bottom edge, as before.
  • A page without its own StreamScaffold shows its snackbars on the StreamScaffold beneath it: behind a dialog's barrier, or hidden under an opaque page. With resizeToAvoidBottomInset: false, the snackbar sits behind the keyboard. Both match Flutter's Scaffold.
  • Pushing one StreamScaffold over another flies the snackbar across as one copy, like Flutter's SnackBar hero. Which copy carries the hero follows how Flutter collects heroes, including nested navigators (tabs, go_router shells): the visible copy flies, and two never carry it at once, so Hero's duplicate-tag assert can't fire where Flutter's own Scaffold would.
  • In debug, a snackbar that the bottom leaves no room for throws "StreamSnackbar presented off screen.", as Flutter's Scaffold does.
  • StreamSnackbarPopup is unchanged.

Why

The app-wide scope draws at the bottom of the screen and has no idea what the page docks there. In chat, snackbars from the message list (copy, pin, delete…) covered the composer, and on the channel list they covered the bottom nav bar.

This follows Flutter's ScaffoldMessenger model: one queue at the scope, every top-level scaffold shows it, a nested scaffold leaves it to the outer one. Because the queue stays at the scope, a snackbar shown just before a page pops still appears on the page below. Like Flutter, the snackbar snaps (rather than animates) to its new position when the bottom chrome changes.

Before / after

Pinning a message in the stream-chat-flutter sample (Pixel 10 emulator), against the currently pinned core vs this branch:

Before After
Above the composer Keyboard open Floating nav bar Dialog without / with a StreamScaffold

Verified

  • Placement, padding and body layout (10 tests): stream_scaffold_snackbar_placement_test.dart
  • Surfaces, routes, dialogs, hero flights (root and nested navigators, tabs, back swipe), side-by-side panes, semantics, ticking and focus (30 tests): stream_snackbar_scope_surface_test.dart
  • Scope messenger lifecycle and swaps (5 tests): stream_snackbar_scope_messenger_test.dart
  • Golden matrix (light and dark): docked and floating bottom.
  • Gallery use-case Scaffold → Snackbars.
  • flutter test --exclude-tags golden: 526 passing; the scaffold and snackbar suites pass on Flutter 3.44.0 and 3.47.7; analyze, format:verify and check:barrels clean.
  • Pixel 10 emulator, this branch: placement (docked, floating, gesture inset, keyboard both ways), show-then-pop, a dialog without a StreamScaffold, and the off-screen error. Earlier on 0ec5f27, where the scope and snackbar slot are unchanged: a StreamScaffold in a dialog, Undo/swipe, the loading spinner, one TalkBack node, and the composer's StreamSnackbarPopup unchanged. Tab/D-pad focus is unit-tested only.
  • stream-chat-flutter v11 on this branch (Pixel 10 emulator): the voice-recording hint and the command-unavailable error with the attachment picker open both rest above the composer, fully visible.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Snackbars in a StreamSnackbarScope now appear within the top-level StreamScaffold, above its bottom content. Pages without their own scaffold display the snackbar on the scaffold beneath them.
    • Added a snackbar example to the StreamScaffold gallery, with regular and floating bottom-bar styles.
  • Behavior
    • Snackbars move with their scaffold across navigation and are shown on the outermost applicable scaffold. When no scaffold surface is available, the scope displays the snackbar at its bottom edge.

A snackbar shown through StreamSnackbarScope now shows inside the
StreamScaffold, resting above its bottom slot instead of covering it.
Adds StreamScaffold.bottomAccessory, a widget that floats just above
bottom and that snackbars also rest above.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@xsahil03x
xsahil03x requested a review from a team as a code owner November 3, 2026 16:57
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

StreamScaffold now displays scoped snackbars above its bottom content. StreamSnackbarScope coordinates presentation across eligible scaffold surfaces and retains a fallback when none are registered. Tests cover placement, lifecycle behavior, and messenger changes. The gallery adds a snackbar use case.

Changes: Scoped Snackbar and Scaffold Integration

Layer / File(s) Summary
Scoped snackbar presentation
packages/stream_core_flutter/lib/src/components/snackbar/*, packages/stream_core_flutter/lib/core.dart, packages/stream_core_flutter/test/components/scaffold/stream_snackbar_scope_surface_test.dart, packages/stream_core_flutter/test/components/snackbar/stream_snackbar_scope_messenger_test.dart
StreamSnackbarScope shares presenter state with registered surfaces and suppresses its fallback while a surface is active. Presenter logic manages queue updates, animation, dismissal, messenger changes, and disposal. Tests cover surface behavior, fallback visibility, accessibility behavior, and messenger replacement. The core barrel hides StreamSnackbarSurface.
Scaffold snackbar layout
packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart, packages/stream_core_flutter/test/components/scaffold/stream_scaffold_*, packages/stream_core_flutter/test/components/scaffold/stream_snackbar_scope_surface_test.dart, packages/stream_core_flutter/CHANGELOG.md
StreamScaffold adds a snackbar layout slot and positions snackbars above its bottom inset. It removes top and bottom media padding from the snackbar. Tests cover placement, keyboard behavior, body sizing, off-screen errors, and light- and dark-theme goldens. The changelog describes scoped snackbar placement.
Gallery example and internal-class guidance
apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart, apps/design_system_gallery/lib/app/gallery_app.directories.g.dart, STYLE_GUIDE.md
The gallery adds a snackbar use case with selectable bottom-bar styling and a button that displays a success snackbar. The style guide documents the barrel-export and @internal rule for shared non-public classes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant StreamSnackbarMessenger
  participant _SnackbarPresenter
  participant StreamSnackbarScope
  participant StreamSnackbarSurface
  participant StreamScaffold
  StreamSnackbarMessenger->>_SnackbarPresenter: Update snackbar queue
  StreamSnackbarSurface->>StreamSnackbarScope: Register surface
  StreamSnackbarScope->>StreamSnackbarSurface: Provide current snackbar
  StreamSnackbarSurface->>StreamScaffold: Supply snackbar for layout
Loading

Suggested reviewers: renefloor, brazol

Merge Risk: Minimal · up to fa14a

No actionable merge-blocking issue was established; the change is ready for normal merge checks.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: displaying scope snackbars above StreamScaffold bottom content.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The pull request description is complete and relevant. It explains the behavior change, motivation, edge cases, screenshots, testing coverage, and verification results. It does not include the templat…
Full details: Description check

Explanation

The pull request description is complete and relevant. It explains the behavior change, motivation, edge cases, screenshots, testing coverage, and verification results. It does not include the template’s Linear/GitHub fields or CLA checklist, but these omissions do not prevent review of the implementation.

Finishing Touches

Generate unit tests (beta)

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b5b8534b-8132-4ee8-a077-19c431c586cb
Commits

Reviewing files that changed from the base of the PR and between 0ec5f27 and fa14a7e.

Files ignored due to path filters: 2

  • packages/stream_core_flutter/test/components/scaffold/goldens/ci/stream_scaffold_snackbar_dark_matrix.png is excluded by !**/*.png
  • packages/stream_core_flutter/test/components/scaffold/goldens/ci/stream_scaffold_snackbar_light_matrix.png is excluded by !**/*.png
Files selected for processing: 8

  • apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart
  • packages/stream_core_flutter/CHANGELOG.md
  • packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart
  • packages/stream_core_flutter/lib/src/components/snackbar/stream_snackbar_messenger.dart
  • packages/stream_core_flutter/test/components/scaffold/stream_scaffold_golden_test.dart
  • packages/stream_core_flutter/test/components/scaffold/stream_scaffold_snackbar_placement_test.dart
  • packages/stream_core_flutter/test/components/scaffold/stream_snackbar_scope_surface_test.dart
  • packages/stream_core_flutter/test/components/snackbar/stream_snackbar_scope_messenger_test.dart
Files skipped from review as they are similar to previous changes: 1

  • packages/stream_core_flutter/CHANGELOG.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Adds a Snackbars use-case under Scaffold, with knobs for the bottom bar
style and the bottom accessory, and a golden matrix of a snackbar resting
above a docked bottom, a floating bottom and a bottom accessory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Fix comments: Fix CodeRabbit comments on this PR

xsahil03x and others added 6 commits November 3, 2026 18:09
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…amScaffold

A StreamScaffold now shows scope snackbars only while its route is
current, so a plain page or a dialog pushed on top shows them at the
scope's bottom edge instead of hiding them inside the covered page.

Also from review: a scope snackbar's action and dismiss act on the scope's
messenger, a default StreamSnackbarPopup only defers to a scaffold of its
own scope, and the popup docs no longer link the internal surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nackbars

Drops the route tracking and the StreamSnackbarPopup deferral. Every
top-level StreamScaffold shows the scope's snackbar above its bottom
slots, as Flutter's Scaffold does; StreamSnackbarPopup behaves as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…senger

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… pops

Also drops the snackbar library's doc-only import of the scaffold and
spells out the scope fallback in the changelog entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.21451% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.72%. Comparing base (5d57aed) to head (d2216b4).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...components/snackbar/stream_snackbar_messenger.dart 95.66% 12 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #212      +/-   ##
==========================================
+ Coverage   71.99%   72.72%   +0.72%     
==========================================
  Files         216      216              
  Lines        9067     9309     +242     
==========================================
+ Hits         6528     6770     +242     
  Misses       2539     2539              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

xsahil03x and others added 5 commits November 4, 2026 15:46
- Show the scope's snackbar in the frame the last StreamScaffold leaves:
  surfaces leave on deactivate, and the scope's copy is gated at paint
  time instead of a post-frame rebuild.
- Keep the scope's hidden copy out of tickers, focus and semantics.
- Strip vertical padding from the snackbar and bottom accessory slots.
- Finish a leaving snackbar when the scope's messenger is swapped or the
  scope is removed mid-exit.
- Document where snackbars show without a StreamScaffold and with
  resizeToAvoidBottomInset: false; mark StreamSnackbarSurface @internal.
- Exact-position tests, new placement cases, and a floating-bottom plus
  accessory golden scenario.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In debug, a StreamScaffold whose bottom and bottomAccessory leave no room
for a snackbar now throws "StreamSnackbar presented off screen." instead
of placing it above the top of the body.

Also splits the scaffold snackbar tests into placement, surface and
scope-messenger files, and tightens the StreamSnackbarScope and
StreamSnackbarSurface docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eds it

The accessory slot had no user yet, and how the composer's own snackbar
popup should interact with it is still open. Snackbars from the scope
keep resting above the scaffold's bottom.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@renefloor renefloor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with the pr-review-toolkit agents (code, tests, comments). The overall design holds up: registration/reparenting, ticker ownership on the scope, the TickerCanceled/microtask exit handling and the paint-time hiding of the scope's copy all check out. Inline comments cover the two bugs and the doc nits. The test gaps are listed here:

Missing tests

  • One StreamScaffold page pushed over another (channel list → channel): one painted copy, one semantics node, placed against the top page's bottom, and back on the lower page's bottom after a pop.
  • The visible scaffold changes during the enter animation: the timeout should fire once, ~5 s after show.
  • Swipe through a scaffold copy: closed == swipe, the next queued snackbar shows, and no Dismissible error (two Dismissibles now share ObjectKey(hosted)).
  • Nested scopes with a scaffold at each level (chat's per-channel scope), and body State/focus surviving a snackbar coming and going.

Weak assertions

  • The action test could also check closed == action and that onPressed was called.
  • The nested test's inner scaffold could get its own bottom, to prove that bottom is ignored.
  • The messenger-swap test could check that the old messenger still has its snackbar queued.

Design points, for discussion

  • In debug, the off-screen assert can fire in a cramped but legitimate layout (phone in landscape, keyboard open, tall composer).
  • Downstream, stream_chat_flutter sheets/dialogs without a StreamScaffold (e.g. the attachment picker's permission snackbars) now show their snackbar under the barrier.
  • Side-by-side top-level scaffolds (tablet master/detail) mean a screen reader announces the snackbar twice.

🤖 Generated with Claude Code

- Drop the old snackbar when the scope's messenger is swapped, even to an
  empty one, and resolve the action against the current messenger.
- Lay the body out with the constraints the region was given.
- Fly the snackbar between StreamScaffold pages as one copy; of the
  surfaces in a route, only the first carries the hero.
- Tighten the scope and surface docs and the changelog entry, and add the
  missing tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@xsahil03x

Copy link
Copy Markdown
Member Author

@renefloor, on your review:

Thanks, this was a thorough pass. In fa14a7e:

Tests. All the missing tests are added: scaffold over scaffold (one painted copy, one semantics node, moves on push and back on pop), the timeout firing once ~5 s after show when a page is pushed during the entrance, swipe (closed == swipe, next one shows, no Dismissible error), nested scopes with a scaffold at each level, and body State/focus surviving. The three weak assertions are strengthened as suggested.

Page transitions. Pushing one StreamScaffold over another now flies the snackbar as a single copy, on a push and on a back swipe, rather than cross-fading two copies. This matches SnackBar's Hero in Flutter. The tag is the snackbar instance, so two snackbars with the same text don't collide.

Off-screen assert. I'd keep it. Flutter's Scaffold throws the same kind of error for a floating SnackBar with no room ("Floating SnackBar presented off screen."). A layout that leaves the snackbar no room would paint it off screen in release, so the debug error points at a real problem rather than a false positive.

Attachment picker. On v11, StreamSnackbar does show from the composer: the command-unavailable error, fired from the command picker inside the attachment picker, and the voice-recording hint. Both resolve the messenger from the composer's context, so they reach the app's scope. The picker is inline in the composer, not a modal route, so these snackbars rest above the composer and the open picker rather than under a barrier. I checked this on a device (screenshots below). Permission messages don't go through StreamSnackbar. A sheet or dialog without its own StreamScaffold would show its snackbars on the page beneath, the same as in Flutter, where the fix is a Scaffold or messenger inside the sheet.

Recorder hint Command unavailable, picker open

Side-by-side scaffolds. Matches Flutter: each top-level scaffold shows the snackbar. The scope docs now say so, and how to show it once: one outer StreamScaffold, or a scope of its own for one pane. Here we go one step further than Flutter. With the Hero, two panes in one route showing the same snackbar would trip Hero's duplicate-tag assert on the next push, and Flutter's Scaffold does trip it in that layout. Only the first pane in a route carries the Hero, and the other cross-fades. Covered by two side-by-side tests.

bool leadsRoute(_StreamSnackbarSurfaceState surface) {
final route = surface._route;
if (route == null) return true;
return _surfaces.every((other) => other._route != route || other._order! >= surface._order!);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ModalRoute.of gives only the nearest route, but when the root navigator transitions, Hero._allHeroesFor also collects the Hero on the top page of every nested navigator inside that route, including tabs that aren't visible. So tabs that each have their own Navigator in an IndexedStack (go_router's StatefulShellRoute), each with a top-level StreamScaffold, each lead their own inner route and all carry ObjectKey(hosted). A root push or pop while a snackbar shows then throws:

There are multiple heroes that share the same tag within a subtree … tag: [_HostedSnackbar#1f4f6]

A likely chat case: pin a message in a channel pushed over the tabs, then go straight back. Repro on fa14a7e:

testWidgets('tabs with their own navigators, root push while a snackbar shows', (tester) async {
  final navigatorKey = GlobalKey<NavigatorState>();
  final tabKey = GlobalKey();
  Widget tab(Key? key) => Navigator(
        onGenerateRoute: (_) => MaterialPageRoute<void>(
          builder: (_) => StreamScaffold(key: key, body: const SizedBox.expand()),
        ),
      );
  await tester.pumpWidget(
    MaterialApp(
      navigatorKey: navigatorKey,
      theme: ThemeData(extensions: [StreamTheme.light()]),
      builder: (context, child) => StreamSnackbarScope(child: child!),
      home: Scaffold(body: IndexedStack(children: [tab(tabKey), tab(null)])),
    ),
  );
  StreamSnackbarMessenger.of(tabKey.currentContext!).show(
    StreamSnackbar(message: const Text('hi'), duration: const Duration(minutes: 1)),
  );
  await tester.pumpAndSettle();
  navigatorKey.currentState!
      .push(MaterialPageRoute<void>(builder: (_) => const StreamScaffold(body: SizedBox.expand())))
      .ignore();
  await tester.pump();
  await tester.pump(const Duration(milliseconds: 50));
  expect(tester.takeException(), isNull); // fails with the duplicate-tag error
});

The assert is debug-only. In release, I'd expect the Hero kept for the tag to be the one in the tab you can't see, so a second copy probably shows during the transition. I haven't run that, though.

Two possible fixes: turn the Hero off where tickers are off (HeroMode(enabled: TickerMode.of(context))), which covers tabs that aren't visible, or decide which surface leads using all the routes above it rather than only the nearest, which also covers nested navigators visible side by side.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, reproduced on fa14a7e. A root transition also collects the hero on the current page of every nested navigator in the moving page, so grouping by the nearest route wasn't enough. Flutter's own Scaffold hits the same assert in this layout.

In d2216b4, which surface carries the hero now follows the rule Flutter uses to collect heroes. A surface's hero is collected by its own route's transitions, and while its page is the current one in its navigator, by every route above it too. Surfaces collected by the same transition never carry the hero at the same time. Among them, a visible one takes it from a hidden one (an IndexedStack tab, or a go_router branch with its tickers off), and otherwise whoever has it keeps it. So a page covered by a root push still flies back on pop.

Flights still work as in Flutter:

  • inside a nested navigator, on push, pop and back swipe;
  • between a nested page and a root page, both ways;
  • from the visible tab after a tab switch.

Covered by your repro, a go_router-style Offstage/TickerMode branch, a tab with a page pushed inside it, and a StreamScaffold around the tabs.

A page transition collects heroes from its own page and from the current
page of every navigator nested in it, so tabs with their own navigators
each carried the snackbar hero and a root push hit the duplicate-tag
assert. Surfaces collected by the same transition now never carry it at
once; a visible one takes it from a hidden one, and otherwise the carrier
keeps it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants