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
2 changed files with 176 additions and 3 deletions

View file

@ -1,5 +1,7 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -126,6 +128,12 @@ class const SheetScaffold({
/// The tallest a sheet grows as a dialog.
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
Widget build(BuildContext 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
// height, so the footer sits at the bottom.
if (!isDialog && heightFactor != null)
Expanded(child: SingleChildScrollView(child: body))
Expanded(
child: _ScrollingBody(body: body, fill: true, footer: footer),
)
else
Flexible(child: SingleChildScrollView(child: body)),
Flexible(
child: _ScrollingBody(body: body, footer: 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:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
@ -103,4 +104,67 @@ void main() {
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);
});
}