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.
|
||||
- **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).
|
||||
- **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.
|
||||
|
||||
## Testing
|
||||
|
|
|
|||
|
|
@ -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