fix(shell): the tab screens run on beneath the floating bar
On a phone, a plain band ran behind the floating navigation bar, the screen's content cut off at its top (your screenshot from the Pixel 9a, Home's activity card). The bar's whole slot was kept from the screens, painted in their background: the content stopped above it, as said when the bar came, until the screens were made ready. The shell now lets the tab screens run on beneath the bar (extendBody), as iOS 26's tab bar floats over content. Flutter gives each screen what the bar covers as the body's bottom padding, and every list of the four tabs (Home, the library, Explore, Profile) ends with that much room, so its last item scrolls out from under the bar. Home leaves the Ask field's slot out while asking is off, as an empty slot would take that room from its body; with the field, its sections scroll beneath both. A floating button keeps clear of the system's bar by itself, not of this one: the library's lifts by what the bar covers (FloatingNavigationBar.coverOf). Inside a studyset, where the bar steps aside, nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1d76cde7d9
commit
47743ced49
8 changed files with 133 additions and 9 deletions
|
|
@ -95,7 +95,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
|
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
|
||||||
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
|
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
|
||||||
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
|
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
|
||||||
- **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 (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`) with the background of the screen in view around it (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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 **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
|
- **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 (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), 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 **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
|
||||||
- **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; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `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. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. 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 and the marks of a choice not yet checked (filled box and circle), which need 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`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), 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; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `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. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. 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 and the marks of a choice not yet checked (filled box and circle), which need 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`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
||||||
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,10 @@ class _AppShellState() extends State<AppShell> {
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
builder: (context, background, _) => Scaffold(
|
builder: (context, background, _) => Scaffold(
|
||||||
backgroundColor: background,
|
backgroundColor: background,
|
||||||
|
// The screens run on beneath the floating bar, showing around
|
||||||
|
// it, as iOS 26's tab bar floats over content; their lists end
|
||||||
|
// with room for it (the body's bottom padding says how much).
|
||||||
|
extendBody: !widget.hidesNavigationBar,
|
||||||
body: navigationShell,
|
body: navigationShell,
|
||||||
// Without the bar, a strip as high as the system's own bar, in
|
// Without the bar, a strip as high as the system's own bar, in
|
||||||
// the screen's background, keeps every screen inside clear of
|
// the screen's background, keeps every screen inside clear of
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// A navigation bar detached from the window's bottom edge, as iOS 26's tab
|
/// A navigation bar detached from the window's bottom edge, as iOS 26's tab
|
||||||
|
|
@ -20,6 +22,15 @@ class const FloatingNavigationBar({
|
||||||
/// the bar floats.
|
/// the bar floats.
|
||||||
static const lift = 8.0;
|
static const lift = 8.0;
|
||||||
|
|
||||||
|
/// How much of the window's foot the bar covers beyond the system's own
|
||||||
|
/// bar, for what [context] shows beneath it; none without it. A floating
|
||||||
|
/// button keeps clear of the system's bar by itself, but not of this.
|
||||||
|
static double coverOf(BuildContext context) => math.max(
|
||||||
|
0,
|
||||||
|
MediaQuery.paddingOf(context).bottom -
|
||||||
|
MediaQuery.viewPaddingOf(context).bottom,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,13 @@ class const _Account() extends StatelessWidget {
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
// Room at the end for the navigation bar floating over the foot.
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
isIdentified ? l10n.profileIdentified : l10n.profileAnonymous,
|
isIdentified ? l10n.profileIdentified : l10n.profileAnonymous,
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,13 @@ class const _Results({required final ExploreState state})
|
||||||
_ => RefreshIndicator(
|
_ => RefreshIndicator(
|
||||||
onRefresh: () async => send(ExploreEventFiltered(state.filter)),
|
onRefresh: () async => send(ExploreEventFiltered(state.filter)),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
// Room at the end for the navigation bar floating over the foot.
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
12 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
for (final studyset in state.studysets)
|
for (final studyset in state.studysets)
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,13 @@ class _HomeState() extends State<_Home> {
|
||||||
DateTime? today,
|
DateTime? today,
|
||||||
) => Scaffold(
|
) => Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
bottomNavigationBar: _arranging ? null : const AssistantBar(),
|
// The sections scroll on beneath the Ask field and the navigation bar.
|
||||||
|
// No field, no slot: an empty one would take the room the body keeps
|
||||||
|
// for the navigation bar.
|
||||||
|
extendBody: true,
|
||||||
|
bottomNavigationBar: _arranging || !assistantOn(context)
|
||||||
|
? null
|
||||||
|
: const AssistantBar(),
|
||||||
appBar: _arranging
|
appBar: _arranging
|
||||||
? AppBar(
|
? AppBar(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|
@ -154,13 +160,26 @@ class _HomeState() extends State<_Home> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: loaded == null || today == null
|
child: loaded == null || today == null
|
||||||
? const Center(child: AppLoadingIndicator())
|
? const Center(child: AppLoadingIndicator())
|
||||||
: _dashboard(loaded, today),
|
// Inside the body, which knows how much floats over its foot.
|
||||||
|
: Builder(
|
||||||
|
builder: (context) => _dashboard(
|
||||||
|
loaded,
|
||||||
|
today,
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _dashboard(HomeStateLoaded state, DateTime today) {
|
/// The dashboard, with [bottom] of room at the end of its list for what
|
||||||
|
/// floats over the screen's foot.
|
||||||
|
Widget _dashboard(
|
||||||
|
HomeStateLoaded state,
|
||||||
|
DateTime today, {
|
||||||
|
required double bottom,
|
||||||
|
}) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final selected = _selected ?? today;
|
final selected = _selected ?? today;
|
||||||
Widget section(HomeWidget widget) => switch (widget) {
|
Widget section(HomeWidget widget) => switch (widget) {
|
||||||
|
|
@ -216,7 +235,9 @@ class _HomeState() extends State<_Home> {
|
||||||
: layout.shown.isEmpty
|
: layout.shown.isEmpty
|
||||||
? _AllHidden(onArrange: _arrange)
|
? _AllHidden(onArrange: _arrange)
|
||||||
: ListView(
|
: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
// Room at the end for what floats over the foot: the Ask
|
||||||
|
// field and the navigation bar.
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottom),
|
||||||
children: [
|
children: [
|
||||||
for (final widget in layout.shown)
|
for (final widget in layout.shown)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
|
@ -302,7 +323,12 @@ class const _Arranging({
|
||||||
HomeWidget.activity => l10n.homeWidgetActivity,
|
HomeWidget.activity => l10n.homeWidgetActivity,
|
||||||
};
|
};
|
||||||
return ReorderableListView(
|
return ReorderableListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
16 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
buildDefaultDragHandles: false,
|
buildDefaultDragHandles: false,
|
||||||
onReorderStart: (_) => HapticFeedback.mediumImpact(),
|
onReorderStart: (_) => HapticFeedback.mediumImpact(),
|
||||||
proxyDecorator: (child, index, animation) => AnimatedBuilder(
|
proxyDecorator: (child, index, animation) => AnimatedBuilder(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
|
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/floating_navigation_bar.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
||||||
|
|
@ -207,7 +208,15 @@ class _LibraryState() extends State<_Library> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: fab,
|
// Clear of the navigation bar floating over the foot on a phone.
|
||||||
|
floatingActionButton: fab == null
|
||||||
|
? null
|
||||||
|
: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: FloatingNavigationBar.coverOf(context),
|
||||||
|
),
|
||||||
|
child: fab,
|
||||||
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -402,6 +411,11 @@ class _GridState() extends State<_Grid> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
// Room to scroll the last cards out from under what floats over
|
||||||
|
// the foot: the navigation bar on a phone.
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: MediaQuery.paddingOf(context).bottom),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ import 'package:halkyon_learn_flutter/src/core/widget/screen_tint.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/home/widget/activity_heatmap.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -196,6 +198,61 @@ void main() {
|
||||||
expect(find.byType(NavigationRail), findsOneWidget);
|
expect(find.byType(NavigationRail), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('on a phone, the tab screens run on beneath the floating bar, '
|
||||||
|
'and their lists end with room to scroll out from under it', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
studysets.receiveRemoteChange(
|
||||||
|
'extra-$i',
|
||||||
|
encodeStudysetPayload(StudysetContent.titled('Extra $i')),
|
||||||
|
position: 'b$i',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tester.view.padding = const FakeViewPadding(bottom: 24);
|
||||||
|
addTearDown(tester.view.resetPadding);
|
||||||
|
await showApp(tester, width: 400);
|
||||||
|
await openTab(tester, 'Library');
|
||||||
|
|
||||||
|
final pill = tester.getRect(
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: find.byType(FloatingNavigationBar),
|
||||||
|
matching: find.byType(Material),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
expect(tester.getBottomLeft(find.byType(LibraryScreen)).dy, 900);
|
||||||
|
// Before scrolling, which slides it away.
|
||||||
|
expect(
|
||||||
|
tester.getRect(find.byTooltip('Create studyset')).bottom,
|
||||||
|
lessThan(pill.top),
|
||||||
|
reason: "the Library's button stands clear of the bar",
|
||||||
|
);
|
||||||
|
await tester.fling(
|
||||||
|
find.byType(CustomScrollView),
|
||||||
|
const Offset(0, -5000),
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final last = tester.getRect(find.text('Extra 11'));
|
||||||
|
expect(last.bottom, lessThan(pill.top));
|
||||||
|
|
||||||
|
await openTab(tester, 'Home');
|
||||||
|
expect(tester.getBottomLeft(find.byType(HomeScreen)).dy, 900);
|
||||||
|
await tester.fling(
|
||||||
|
find.byType(ListView).first,
|
||||||
|
const Offset(0, -5000),
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.getRect(find.byType(ActivityHeatmap)).bottom,
|
||||||
|
lessThan(pill.top),
|
||||||
|
reason: "Home's last section scrolls out from under the bar",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
|
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
|
||||||
'the label of the tab in view in the secondary colour', (tester) async {
|
'the label of the tab in view in the secondary colour', (tester) async {
|
||||||
await showApp(tester, width: 400);
|
await showApp(tester, width: 400);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue