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:
parent
7a2f1a96d9
commit
11a16f5686
3 changed files with 111 additions and 41 deletions
|
|
@ -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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue