fix(theme): snack bars float above the navigation bar, lined up with it

Flutter's default snack bar is Material 2's fixed one, a rectangle across
the window's foot, which sat squarely on the floating, rounded
navigation bar. Material 3 places snack bars floating above the
navigation bar with small rounded corners: they now stand 8 dp above
it and 16 dp in from the sides, as the bar does, and from medium
windows on they are 560 dp wide and centred rather than as wide as the
window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 18:22:14 +02:00
commit 11a16f5686
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 111 additions and 41 deletions

View file

@ -67,45 +67,47 @@ class _PersonAppState() extends State<PersonApp> {
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router,
// The person's data opens around the router's navigator, so every screen
// and dialog is inside it.
//
// Packages that still import package:flutter/material.dart (Serverpod's
// sign-in widgets, the rich-text editor, go_router's pages) see this app's
// theme and localizations only through the bridge, since material_ui is a
// separate copy of that library. The bridge is deprecated on purpose, as a
// reminder: remove it once they have moved.
// ignore: deprecated_member_use
builder: (context, child) => MaterialUiCompatibilityBridge(
child: PersonScope(
person: widget.person,
child: MultiBlocProvider(
providers: [
// Where sync stands: the trouble dot, Settings and Home show it.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of(context).sync)
..add(const SyncStatusEventStarted()),
// and dialog is inside it; snack bars fit the window around it all.
builder: (context, child) => FittedSnackBars(
// Packages that still import package:flutter/material.dart
// (Serverpod's sign-in widgets, the rich-text editor, go_router's
// pages) see this app's theme and localizations only through the
// bridge, since material_ui is a separate copy of that library. The
// bridge is deprecated on purpose, as a reminder: remove it once they
// have moved.
// ignore: deprecated_member_use
child: MaterialUiCompatibilityBridge(
child: PersonScope(
person: widget.person,
child: MultiBlocProvider(
providers: [
// Where sync stands: the trouble dot, Settings and Home show it.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of(context).sync)
..add(const SyncStatusEventStarted()),
),
// What is new in the inbox, for the Profile tab's badge.
BlocProvider(
create: (context) =>
InboxCountBloc(PersonScope.of(context).feedback)
..add(const InboxCountEventRefreshed()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(
PersonScope.of(context).settings,
PersonScope.of(context).pomodoroRuns,
stoppedOutside: DependenciesScope.of(
context,
).pomodoro.takeStoppedOutside,
)..add(const PomodoroEventStarted()),
),
],
child: PomodoroPresenter(
// The editors know the features' blocks too.
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
),
// What is new in the inbox, for the Profile tab's badge.
BlocProvider(
create: (context) =>
InboxCountBloc(PersonScope.of(context).feedback)
..add(const InboxCountEventRefreshed()),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(
PersonScope.of(context).settings,
PersonScope.of(context).pomodoroRuns,
stoppedOutside: DependenciesScope.of(
context,
).pomodoro.takeStoppedOutside,
)..add(const PomodoroEventStarted()),
),
],
child: PomodoroPresenter(
// The editors know the features' blocks too.
child: EditorBlocksScope(blocks: _editorBlocks, child: child!),
),
),
),

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
@ -5,8 +6,8 @@ import 'package:material_ui/material_ui.dart';
/// The app's theme for [brightness]: Material 3 with a hand-authored colour
/// scheme, the app's own fonts ([appFontFamily]), [ThemeExtras], the
/// current style of progress indicators, and Material 3 Expressive's
/// shorter navigation bar.
/// current style of progress indicators, Material 3 Expressive's shorter
/// navigation bar, and snack bars floating above it ([FittedSnackBars]).
ThemeData appTheme(Brightness brightness) {
final theme = _baseTheme(brightness);
return theme.copyWith(navigationBarTheme: _flexibleNavigationBar(theme));
@ -54,6 +55,16 @@ ThemeData _baseTheme(Brightness brightness) => ThemeData(
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
),
// Floating, as Material 3 places snack bars: above the navigation bar
// rather than across the window's foot beneath it, in Material 3's
// inverse colours and corners (4 dp); in from the sides and above the bar
// as far as the floating bar stands in and above the system's
// (FloatingNavigationBar.sideMargin and lift). Flutter's default is the
// fixed snack bar of Material 2.
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
insetPadding: EdgeInsets.fromLTRB(16, 8, 16, 8),
),
extensions: [
switch (brightness) {
Brightness.light => ThemeExtras.light,
@ -152,3 +163,27 @@ ColorScheme _darkScheme() =>
inversePrimary: Brand.primary,
surfaceTint: const Color(0xFFAEB9FF),
);
/// Snack bars no wider than [maxWidth] from medium windows on, centred,
/// rather than across the whole window; below, the theme's margins line
/// them up with the floating navigation bar. Around the app's navigator,
/// since the theme can't know the window.
class const FittedSnackBars({required final Widget child, super.key})
extends StatelessWidget {
/// How wide a snack bar is at most, from medium windows on.
static const maxWidth = 560.0;
@override
Widget build(BuildContext context) {
if (!context.windowSize.isAtLeastMedium) {
return child;
}
final theme = Theme.of(context);
return Theme(
data: theme.copyWith(
snackBarTheme: theme.snackBarTheme.copyWith(width: maxWidth),
),
child: child,
);
}
}

View file

@ -150,6 +150,39 @@ void main() {
expect(around(), scheme.surfaceContainerLow);
});
testWidgets('a snack bar floats 8 above the navigation bar, as far in '
'from the sides; from medium windows on 560 wide, centred', (
tester,
) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.resetPadding);
Future<Rect> snackBar() async {
await openTab(tester, 'Library');
ScaffoldMessenger.of(
tester.element(find.byType(LibraryScreen)),
).showSnackBar(const SnackBar(content: Text('Archived')));
await tester.pumpAndSettle();
return tester.getRect(
find.descendant(
of: find.byType(SnackBar),
matching: find.byType(Material),
),
);
}
await showApp(tester, width: 400);
final narrow = await snackBar();
expect(narrow.left, 16);
expect(narrow.right, 400 - 16);
// The bar's top, less 8.
expect(narrow.bottom, 900 - 24 - 8 - 64 - 8);
await showApp(tester);
final wide = await snackBar();
expect(wide.width, 560);
expect(wide.center.dx, 600);
});
testWidgets('on a phone, the navigation bar steps aside inside a '
'studyset and its notes, and comes back in the library; inside, the '
"screens keep clear of the system's bar", (tester) async {