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