feat(app): keep the tabs beside notes and paths, flat app bars, the sync dot on the cogs alone

A studyset's notes and learning paths are routes of the Library tab, as
its screen is, so the navigation stays beside them; studying, cards, PDFs
and settings still take the whole window. App bars stay flat when content
scrolls under them: Material 3's tint flickers as an overscroll crosses
the edge again and again. The sync trouble dot shows on the Settings cogs
only; Home has a cog of its own now, so the Profile tab needn't carry it.

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-08 10:41:49 +02:00
commit 770de51bff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 83 additions and 77 deletions

View file

@ -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, 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. 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 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 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`, 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 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`, and the sheets below).
- **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. - **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.
@ -151,7 +151,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number. - **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Profile tab and the Settings cogs (on Home and Profile), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. - **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. - **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.

View file

@ -72,6 +72,51 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
), ),
], ],
), ),
// A studyset's notes and paths keep the tabs beside them, as
// its screen does: reading and writing them is part of being in
// the library. What needs the whole window (studying, a card,
// a PDF) stays over the tabs.
// A note, new or stored; the new one's route first, as for cards.
GoRoute(
path: '/studysets/:studysetId/notes/new',
builder: (context, state) =>
_noteEditor(context, state.pathParameters['studysetId']!),
),
GoRoute(
path: '/studysets/:studysetId/notes/:noteId',
builder: (context, state) => _noteEditor(
context,
state.pathParameters['studysetId']!,
noteId: state.pathParameters['noteId'],
),
),
// A learning path: worked through, written, and one of its steps
// written.
GoRoute(
path: '/studysets/:studysetId/paths/:pathId',
builder: (context, state) => _pathRunner(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
),
),
GoRoute(
path: '/studysets/:studysetId/paths/:pathId/edit',
builder: (context, state) => _pathEditor(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
),
),
GoRoute(
path: '/studysets/:studysetId/paths/:pathId/steps/:stepId',
builder: (context, state) => _stepEditor(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
state.pathParameters['stepId']!,
),
),
], ],
), ),
StatefulShellBranch( StatefulShellBranch(
@ -137,47 +182,6 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
cardId: state.pathParameters['cardId'], cardId: state.pathParameters['cardId'],
), ),
), ),
// A note, new or stored; the new one's route first, as for cards.
GoRoute(
path: '/studysets/:studysetId/notes/new',
builder: (context, state) =>
_noteEditor(context, state.pathParameters['studysetId']!),
),
GoRoute(
path: '/studysets/:studysetId/notes/:noteId',
builder: (context, state) => _noteEditor(
context,
state.pathParameters['studysetId']!,
noteId: state.pathParameters['noteId'],
),
),
// A learning path: worked through, written, and one of its steps
// written.
GoRoute(
path: '/studysets/:studysetId/paths/:pathId',
builder: (context, state) => _pathRunner(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
),
),
GoRoute(
path: '/studysets/:studysetId/paths/:pathId/edit',
builder: (context, state) => _pathEditor(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
),
),
GoRoute(
path: '/studysets/:studysetId/paths/:pathId/steps/:stepId',
builder: (context, state) => _stepEditor(
context,
state.pathParameters['studysetId']!,
state.pathParameters['pathId']!,
state.pathParameters['stepId']!,
),
),
GoRoute( GoRoute(
path: RoutePaths.readingTimeline, path: RoutePaths.readingTimeline,
builder: (context, state) => const ReadingTimelineScreen(), builder: (context, state) => const ReadingTimelineScreen(),

View file

@ -1,7 +1,6 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.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:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -32,8 +31,8 @@ class const AppShell({
destinations: [ destinations: [
for (final destination in destinations) for (final destination in destinations)
NavigationRailDestination( NavigationRailDestination(
icon: _icon(destination, destination.icon), icon: Icon(destination.icon),
selectedIcon: _icon(destination, destination.selectedIcon), selectedIcon: Icon(destination.selectedIcon),
label: Text(destination.label), label: Text(destination.label),
), ),
], ],
@ -52,8 +51,8 @@ class const AppShell({
destinations: [ destinations: [
for (final destination in destinations) for (final destination in destinations)
NavigationDestination( NavigationDestination(
icon: _icon(destination, destination.icon), icon: Icon(destination.icon),
selectedIcon: _icon(destination, destination.selectedIcon), selectedIcon: Icon(destination.selectedIcon),
label: destination.label, label: destination.label,
), ),
], ],
@ -90,20 +89,10 @@ class const _RailLogo() extends StatelessWidget {
/// A tab: its label and icons. Lucide's icons are outlines with few filled /// A tab: its label and icons. Lucide's icons are outlines with few filled
/// companions: the library switches from a closed to an open book, and the /// companions: the library switches from a closed to an open book, and the
/// other tabs show selection through Material's highlight alone. /// other tabs show selection through Material's highlight alone. No tab
typedef _Destination = ({ /// carries the sync trouble dot: the Settings cogs do, where it leads to its
String label, /// explanation.
IconData icon, typedef _Destination = ({String label, IconData icon, IconData selectedIcon});
IconData selectedIcon,
/// Whether the tab carries the dot while sync is in trouble.
bool showsSyncTrouble,
});
Widget _icon(_Destination destination, IconData icon) =>
destination.showsSyncTrouble
? SyncTroubleBadge(child: Icon(icon))
: Icon(icon);
/// The tabs, in the order of the router's branches. /// The tabs, in the order of the router's branches.
List<_Destination> _destinations(AppLocalizations l10n) => [ List<_Destination> _destinations(AppLocalizations l10n) => [
@ -111,26 +100,20 @@ List<_Destination> _destinations(AppLocalizations l10n) => [
label: l10n.shellTabHome, label: l10n.shellTabHome,
icon: LucideIcons.house, icon: LucideIcons.house,
selectedIcon: LucideIcons.house, selectedIcon: LucideIcons.house,
showsSyncTrouble: false,
), ),
( (
label: l10n.shellTabLibrary, label: l10n.shellTabLibrary,
icon: LucideIcons.book, icon: LucideIcons.book,
selectedIcon: LucideIcons.bookOpen, selectedIcon: LucideIcons.bookOpen,
showsSyncTrouble: false,
), ),
( (
label: l10n.shellTabExplore, label: l10n.shellTabExplore,
icon: LucideIcons.compass, icon: LucideIcons.compass,
selectedIcon: LucideIcons.compass, selectedIcon: LucideIcons.compass,
showsSyncTrouble: false,
), ),
( (
label: l10n.shellTabProfile, label: l10n.shellTabProfile,
icon: LucideIcons.user, icon: LucideIcons.user,
selectedIcon: LucideIcons.user, selectedIcon: LucideIcons.user,
// Settings, where the Sync card explains the trouble, is reached from
// Profile.
showsSyncTrouble: true,
), ),
]; ];

View file

@ -7,7 +7,7 @@ import 'package:material_ui/material_ui.dart';
/// as it is otherwise: syncing, or everything synced, shows nothing. /// as it is otherwise: syncing, or everything synced, shows nothing.
/// ///
/// On the Settings cogs (on Home and Profile), which lead to the Sync card /// On the Settings cogs (on Home and Profile), which lead to the Sync card
/// that explains it, and on the Profile tab. /// that explains it.
class const SyncTroubleBadge({required final Widget child, super.key}) class const SyncTroubleBadge({required final Widget child, super.key})
extends StatelessWidget { extends StatelessWidget {
@override @override

View file

@ -15,6 +15,13 @@ ThemeData appTheme(Brightness brightness) => ThemeData(
// deprecated because it will become the default. // deprecated because it will become the default.
// ignore: deprecated_member_use // ignore: deprecated_member_use
progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false), progressIndicatorTheme: const ProgressIndicatorThemeData(year2023: false),
// App bars stay flat when content scrolls under them: the tint and shadow
// Material 3 adds flicker as an overscroll crosses the edge again and
// again (Flutter issue 124296), and the app reads flat throughout.
appBarTheme: const AppBarThemeData(
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
),
extensions: [ extensions: [
switch (brightness) { switch (brightness) {
Brightness.light => ThemeExtras.light, Brightness.light => ThemeExtras.light,

View file

@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
@ -64,6 +65,21 @@ void main() {
expect(find.byType(NavigationRail), findsNothing); expect(find.byType(NavigationRail), findsNothing);
}); });
testWidgets("a studyset's note keeps the tabs beside it", (tester) async {
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(studysetGateway: studysets, cardGateway: cardGateway),
location: RoutePaths.newNote('japanese'),
);
// Flushes the editor's timers.
await tester.pump(const Duration(seconds: 1));
expect(find.byType(NavigationRail), findsOneWidget);
});
testWidgets('every tab keeps its own screens', (tester) async { testWidgets('every tab keeps its own screens', (tester) async {
await showApp(tester); await showApp(tester);
await openJapanese(tester); await openJapanese(tester);

View file

@ -43,18 +43,14 @@ void main() {
expect(trouble(), findsNothing); expect(trouble(), findsNothing);
}); });
testWidgets('a failed sync puts a dot on the Profile tab and the Settings ' testWidgets('a failed sync puts a dot on the Settings cogs, never on a '
'cogs, and Settings says what is wrong', (tester) async { 'tab, and Settings says what is wrong', (tester) async {
nextSync = Exception('No connection'); nextSync = Exception('No connection');
await showApp(tester); await showApp(tester);
expect( expect(trouble(), findsOneWidget, reason: "Home's cog");
trouble(),
findsNWidgets(2),
reason: "the Profile tab and Home's cog",
);
await openTab(tester, 'Profile'); await openTab(tester, 'Profile');
expect(trouble(), findsNWidgets(2), reason: 'the tab and the cog'); expect(trouble(), findsOneWidget, reason: "Profile's cog");
await tester.tap(find.byTooltip('Settings')); await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();