fix(app): a sheet shows that more of it lies below the visible part

Found on the laptop: the Pomodoro profile's last switches (Do Not
Disturb among them) lay below the visible part of the sheet, and
nothing said so: the body scrolled right up to the pinned buttons.
Every sheet (SheetScaffold) now has, as Material 3 has scrolling
dialogs, a line above the footer while some of the body lies below and
one under the title while some lies above, and a scrollbar beside the
body, always shown on the desktop in place of the one Material shows
only while scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 11:36:53 +02:00
commit 2deabcb5b0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 176 additions and 3 deletions

View file

@ -98,7 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. What needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. What needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
## Testing ## Testing

View file

@ -1,5 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart'; import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -126,6 +128,12 @@ class const SheetScaffold({
/// The tallest a sheet grows as a dialog. /// The tallest a sheet grows as a dialog.
static const dialogMaxHeight = 640.0; static const dialogMaxHeight = 640.0;
/// The line under the title while some of the body lies above.
static const moreAboveKey = ValueKey('sheet-more-above');
/// The line above the footer while some of the body lies below.
static const moreBelowKey = ValueKey('sheet-more-below');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final media = MediaQuery.of(context); final media = MediaQuery.of(context);
@ -142,9 +150,13 @@ class const SheetScaffold({
// Loose, so a short body keeps the sheet short; tight at a fixed // Loose, so a short body keeps the sheet short; tight at a fixed
// height, so the footer sits at the bottom. // height, so the footer sits at the bottom.
if (!isDialog && heightFactor != null) if (!isDialog && heightFactor != null)
Expanded(child: SingleChildScrollView(child: body)) Expanded(
child: _ScrollingBody(body: body, fill: true, footer: footer),
)
else else
Flexible(child: SingleChildScrollView(child: body)), Flexible(
child: _ScrollingBody(body: body, footer: footer),
),
if (footer case final footer?) ...[const SizedBox(height: 12), footer], if (footer case final footer?) ...[const SizedBox(height: 12), footer],
], ],
); );
@ -238,3 +250,100 @@ class const _Header({
], ],
); );
} }
/// The sheet's body, scrolling, with a scrollbar beside it (always shown on
/// the desktop, where a wheel or touchpad scrolls without showing one) and,
/// as Material 3 has scrolling dialogs, a line under the title while some of
/// it lies above, and one above the [footer] while some of it lies below
/// (decided 2026-10-07: a form's last switches went unnoticed below the
/// buttons). [fill] takes the whole height given, rather than hugging the
/// body.
class const _ScrollingBody({
required final Widget body,
required final Widget? footer,
final bool fill = false,
}) extends StatefulWidget {
@override
State<_ScrollingBody> createState() => _ScrollingBodyState();
}
const _bodyKey = ValueKey('sheet-body');
class _ScrollingBodyState() extends State<_ScrollingBody> {
final _scroll = ScrollController();
var _moreAbove = false;
var _moreBelow = false;
void _update(ScrollMetrics metrics) {
final moreAbove = metrics.pixels > metrics.minScrollExtent;
final moreBelow = metrics.pixels < metrics.maxScrollExtent;
if (moreAbove != _moreAbove || moreBelow != _moreBelow) {
setState(() {
_moreAbove = moreAbove;
_moreBelow = moreBelow;
});
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final desktop = switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
// The lines keep their room while hidden, so the body doesn't jump.
Widget line({required bool shown, required Key key}) =>
shown ? Divider(key: key, height: 1) : const SizedBox(height: 1);
final scrolling = NotificationListener<ScrollMetricsNotification>(
// Also when the body's size changes, as a section opens.
onNotification: (notification) {
_update(notification.metrics);
return false;
},
child: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) {
_update(notification.metrics);
return false;
},
child: Scrollbar(
controller: _scroll,
thumbVisibility: desktop,
// In place of the one Material's scroll behaviour adds on the
// desktop, shown only while scrolling.
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(
context,
).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: _scroll,
child: widget.body,
),
),
),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
line(shown: _moreAbove, key: SheetScaffold.moreAboveKey),
// Keyed, so that a line coming or going beside it keeps the scroll
// view and its position.
if (widget.fill)
Expanded(key: _bodyKey, child: scrolling)
else
Flexible(key: _bodyKey, child: scrolling),
if (widget.footer != null)
line(shown: _moreBelow, key: SheetScaffold.moreBelowKey),
],
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
@ -103,4 +104,67 @@ void main() {
expect(find.text('New studyset'), findsNothing); expect(find.text('New studyset'), findsNothing);
}); });
group('a body longer than the sheet', () {
final tall = Column(
children: [for (var i = 0; i < 40; i++) const Text('Line')],
);
Finder below() => find.byKey(SheetScaffold.moreBelowKey);
Finder above() => find.byKey(SheetScaffold.moreAboveKey);
testWidgets('shows a line above the buttons while more lies below, and '
'one under the title while more lies above', (tester) async {
// As a dialog: a modal bottom sheet takes a test's drag itself.
await open(tester, width: 1000, body: tall);
expect(below(), findsOneWidget);
expect(above(), findsNothing);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -300),
);
await tester.pumpAndSettle();
expect(below(), findsOneWidget);
expect(above(), findsOneWidget);
expect(
tester
.state<ScrollableState>(
find.descendant(
of: find.byType(SingleChildScrollView),
matching: find.byType(Scrollable),
),
)
.position
.pixels,
greaterThan(0),
reason: 'the line coming keeps where the body was scrolled to',
);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -5000),
);
await tester.pumpAndSettle();
expect(below(), findsNothing);
expect(above(), findsOneWidget);
});
testWidgets('has a scrollbar, always shown on the desktop', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await open(tester, width: 1000, body: tall);
final scrollbar = tester.widget<Scrollbar>(find.byType(Scrollbar));
expect(scrollbar.thumbVisibility, isTrue);
debugDefaultTargetPlatformOverride = null;
});
});
testWidgets('a body that fits shows no lines', (tester) async {
await open(tester, width: 400);
expect(find.byKey(SheetScaffold.moreBelowKey), findsNothing);
expect(find.byKey(SheetScaffold.moreAboveKey), findsNothing);
});
} }