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:
parent
ead9c95049
commit
2deabcb5b0
2 changed files with 176 additions and 3 deletions
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue