feat(theme): the gradient on every main screen, and the rail on its colour

Library, Explore, Profile, Settings and Connect Claude now wash cobalt
down from their top as Home does, as far as their header (200 dp);
Home's wash reaches 360 dp, a studyset's 280.

From medium windows on, the window takes the colour of the screen in
view, as Vaia lays it out: the rail sits on it, transparent and without
a divider, and the screen is a rounded panel inset beside it, with its
own wash at the top. The colour is claimed by the screen's TopGradient
while its tab shows and its route is on top (ScreenTint), so the rail
turns maroon beside a cerise studyset and back to cobalt on leaving it,
in a quarter of a second. The frame is as strong as the rail's labels
allow (WCAG AA on onSurfaceVariant, every palette colour, light and
dark), weaker towards the foot.

Two things the panel's 24 dp less room brought out: a long description
was laid out whole for its first frame, before it was measured, and
overflowed a shorter column; it is now laid out whole but shown no
taller than its preview from the start. And between 840 and about 960 dp
of screen, a studyset's app bar (Study, Add material, Share, options)
didn't fit; Add material is an icon there, its label in the tooltip.

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:
Tizian 「ティツィアーン」 2026-10-10 13:26:14 +02:00
commit d76a49b1c4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 743 additions and 324 deletions

View file

@ -97,7 +97,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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. 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, 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 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 Home (cobalt) and a studyset's screen (its colour), behind a clear app bar, its top tone held to WCAG AA for the theme's text. `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`, `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 a studyset's screen (its colour), behind a clear app bar, its top tone held to WCAG AA for the theme's text. From medium windows on, the shell takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top): the rail sits on it, transparent, and the screen is a rounded panel inset beside it (as Vaia does, decided 2026-10-10). `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`, `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).
- **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.
- **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.

View file

@ -2,6 +2,8 @@ import 'package:flutter/services.dart';
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/widget/screen_tint.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.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';
@ -10,6 +12,11 @@ import 'package:material_ui/material_ui.dart';
/// own stack of screens, and the navigation stays visible on all of them; /// own stack of screens, and the navigation stays visible on all of them;
/// screens that need the whole window are routes outside the shell. /// screens that need the whole window are routes outside the shell.
/// ///
/// From medium windows on, the window takes the colour of the screen in view
/// ([ScreenTint], decided 2026-10-10, as Vaia does): the rail sits on it,
/// and the screen is a rounded panel inset on it, with its own wash at the
/// top. Below that, each screen washes its top itself.
///
/// Choosing the tab that is already selected goes back to its first screen, /// Choosing the tab that is already selected goes back to its first screen,
/// as Material prescribes. Every choice ticks: the lightest haptic the /// as Material prescribes. Every choice ticks: the lightest haptic the
/// platform has (a clock tick on Android), where the device has a motor and /// platform has (a clock tick on Android), where the device has a motor and
@ -22,12 +29,26 @@ class const AppShell({
/// without. /// without.
final int Function(BuildContext context)? profileBadge, final int Function(BuildContext context)? profileBadge,
super.key, super.key,
}) extends StatelessWidget { }) extends StatefulWidget {
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState() extends State<AppShell> {
final _tint = ScreenTint();
@override
void dispose() {
_tint.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigationShell = widget.navigationShell;
final destinations = _destinations(AppLocalizations.of(context)); final destinations = _destinations(AppLocalizations.of(context));
// The Profile tab says how many things are new behind it. // The Profile tab says how many things are new behind it.
final unseen = profileBadge?.call(context) ?? 0; final unseen = widget.profileBadge?.call(context) ?? 0;
Widget icon(_Destination destination, IconData data) => Widget icon(_Destination destination, IconData data) =>
identical(destination, destinations.last) identical(destination, destinations.last)
? Badge.count( ? Badge.count(
@ -37,51 +58,117 @@ class const AppShell({
) )
: Icon(data); : Icon(data);
if (context.windowSize.isAtLeastMedium) { if (context.windowSize.isAtLeastMedium) {
return Scaffold( final scheme = Theme.of(context).colorScheme;
body: Row( return ScreenTintScope(
children: [ notifier: _tint,
NavigationRail( child: Scaffold(
selectedIndex: navigationShell.currentIndex, backgroundColor: scheme.surface,
labelType: NavigationRailLabelType.all, body: ListenableBuilder(
onDestinationSelected: _select, listenable: _tint,
leading: const _RailLogo(), builder: (context, child) => TweenAnimationBuilder<Color?>(
destinations: [ tween: ColorTween(end: _tint.color),
for (final destination in destinations) duration: const Duration(milliseconds: 250),
NavigationRailDestination( builder: (context, color, child) => DecoratedBox(
icon: icon(destination, destination.icon), decoration: BoxDecoration(
selectedIcon: icon(destination, destination.selectedIcon), gradient: TopGradient.frame(scheme, color ?? _tint.color),
label: Text(destination.label), ),
), child: child,
),
child: child,
),
child: Row(
children: [
NavigationRail(
backgroundColor: Colors.transparent,
selectedIndex: navigationShell.currentIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: _select,
leading: const _RailLogo(),
destinations: [
for (final destination in destinations)
NavigationRailDestination(
icon: icon(destination, destination.icon),
selectedIcon: icon(
destination,
destination.selectedIcon,
),
label: Text(destination.label),
),
],
),
Expanded(child: _Panel(child: navigationShell)),
], ],
), ),
const VerticalDivider(thickness: 1, width: 1), ),
Expanded(child: navigationShell),
],
), ),
); );
} }
return Scaffold( return ScreenTintScope(
body: navigationShell, notifier: _tint,
bottomNavigationBar: NavigationBar( child: Scaffold(
selectedIndex: navigationShell.currentIndex, body: navigationShell,
onDestinationSelected: _select, bottomNavigationBar: NavigationBar(
destinations: [ selectedIndex: navigationShell.currentIndex,
for (final destination in destinations) onDestinationSelected: _select,
NavigationDestination( destinations: [
icon: icon(destination, destination.icon), for (final destination in destinations)
selectedIcon: icon(destination, destination.selectedIcon), NavigationDestination(
label: destination.label, icon: icon(destination, destination.icon),
), selectedIcon: icon(destination, destination.selectedIcon),
], label: destination.label,
),
],
),
), ),
); );
} }
void _select(int index) { void _select(int index) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
navigationShell.goBranch( widget.navigationShell.goBranch(
index, index,
initialLocation: index == navigationShell.currentIndex, initialLocation: index == widget.navigationShell.currentIndex,
);
}
}
/// The screen in view from medium windows on: a rounded panel inset on the
/// frame, beside the rail, with a faint edge. The window's own insets (a
/// tablet's status bar) are outside it, so the screen inside has none.
class const _Panel({required final Widget child}) extends StatelessWidget {
/// The margin around it.
static const _gap = 12.0;
/// How round its corners are.
static const _radius = 16.0;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final insets = MediaQuery.paddingOf(context);
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_radius),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.6)),
);
return Padding(
padding: EdgeInsets.fromLTRB(
0,
insets.top + _gap,
insets.right + _gap,
insets.bottom + _gap,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: ShapeDecoration(color: scheme.surface, shape: shape),
foregroundDecoration: ShapeDecoration(shape: shape),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
removeRight: true,
removeBottom: true,
child: child,
),
),
); );
} }
} }

View file

@ -0,0 +1,34 @@
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:material_ui/material_ui.dart';
/// The colour of the screen in view, for the frame around the tabs to take
/// (decided 2026-10-10, as Vaia does): from medium windows on, the navigation
/// rail and the margin around the content panel show it, maroon beside a
/// cerise studyset, cobalt elsewhere. A screen with a `TopGradient` claims
/// its colour while it is the one in view; cobalt until one does.
class ScreenTint() extends ChangeNotifier {
Color _color = Brand.primary;
/// The colour claimed last.
Color get color => _color;
/// Shows [color] from now on.
void show(Color color) {
if (color != _color) {
_color = color;
notifyListeners();
}
}
}
/// Hands the [ScreenTint] of the frame around the tabs down to their screens.
class const ScreenTintScope({
required super.notifier,
required super.child,
super.key,
}) extends InheritedNotifier<ScreenTint> {
/// The tint above [context], without depending on its changes: for a screen
/// that claims it; null outside the tabs.
static ScreenTint? maybeOf(BuildContext context) =>
context.getInheritedWidgetOfExactType<ScreenTintScope>()?.notifier;
}

View file

@ -1,8 +1,13 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/widget/screen_tint.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// A colour washing down from the top of a screen into its [background]: /// A colour washing down from the top of a screen into its [background]:
/// cobalt on Home, a studyset's own colour on its screen. /// cobalt on the tabs' screens and Settings, a studyset's own colour on its
/// screen (decided 2026-10-10, as Vaia does). While the screen is the one in
/// view (its tab showing, its route on top), it gives the frame around the
/// tabs its colour too ([ScreenTint]): the navigation rail takes it.
/// ///
/// The wash starts as [color] mixed into the background, 60 % of it in dark /// The wash starts as [color] mixed into the background, 60 % of it in dark
/// mode (a deep tone, cobalt a navy) and 35 % in light mode (a pastel tint), /// mode (a deep tone, cobalt a navy) and 35 % in light mode (a pastel tint),
@ -24,67 +29,74 @@ class const TopGradient({
/// The screen, transparent where the wash should show. /// The screen, transparent where the wash should show.
required final Widget child, required final Widget child,
/// How far below the status bar the wash reaches. /// How far below the status bar the wash reaches: far on Home and a
final double extent = 360, /// studyset, as far as the header elsewhere.
final double extent = 200,
super.key, super.key,
}) extends StatelessWidget { }) extends StatefulWidget {
/// The share of [color] at the top in dark mode. /// The share of [color] at the top in dark mode.
static const darkMix = 0.6; static const darkMix = 0.6;
/// The share of [color] at the top in light mode. /// The share of [color] at the top in light mode.
static const lightMix = 0.35; static const lightMix = 0.35;
/// The contrast the theme's text keeps on the top tone (WCAG AA). /// The contrast text keeps on a tone (WCAG AA).
static const minContrast = 4.5; static const minContrast = 4.5;
/// How much of the top's share of [color] the frame keeps at its foot.
static const frameFootShare = 0.4;
@override @override
Widget build(BuildContext context) { State<TopGradient> createState() => _TopGradientState();
final top = topTone(Theme.of(context).colorScheme, color, background);
return ColoredBox(
color: background,
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: MediaQuery.paddingOf(context).top + extent,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [top, background],
),
),
),
),
child,
],
),
);
}
/// The tone at the very top for [color] over [background] in [scheme]: as /// The tone at the very top for [color] over [background] in [scheme]: as
/// much of [color] as the scheme's brightness allows, less until /// much of [color] as the scheme's brightness allows ([share] of it), less
/// [ColorScheme.onSurface] keeps [minContrast] on it. /// until [text] (the scheme's `onSurface` without) keeps [minContrast] on
static Color topTone(ColorScheme scheme, Color color, Color background) { /// it.
final text = scheme.onSurface; static Color topTone(
final strongest = switch (scheme.brightness) { ColorScheme scheme,
Brightness.dark => darkMix, Color color,
Brightness.light => lightMix, Color background, {
}; Color? text,
double share = 1,
}) {
final on = text ?? scheme.onSurface;
final strongest =
share *
switch (scheme.brightness) {
Brightness.dark => darkMix,
Brightness.light => lightMix,
};
for (var step = 0; step <= 20; step++) { for (var step = 0; step <= 20; step++) {
final mix = strongest * (1 - step / 20); final mix = strongest * (1 - step / 20);
final tone = Color.lerp(background, color, mix)!; final tone = Color.lerp(background, color, mix)!;
if (contrast(tone, text) >= minContrast) { if (contrast(tone, on) >= minContrast) {
return tone; return tone;
} }
} }
return background; return background;
} }
/// The frame around the tabs from medium windows on (the navigation rail,
/// the margin around the content panel): [color] from the top down, as
/// strong as the rail's labels (`onSurfaceVariant`) allow, weaker towards
/// the foot, where it keeps [frameFootShare] of it.
static LinearGradient frame(ColorScheme scheme, Color color) =>
LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
topTone(scheme, color, scheme.surface, text: scheme.onSurfaceVariant),
topTone(
scheme,
color,
scheme.surface,
text: scheme.onSurfaceVariant,
share: frameFootShare,
),
],
);
/// The contrast ratio of [a] and [b], as WCAG defines it: 1 to 21. /// The contrast ratio of [a] and [b], as WCAG defines it: 1 to 21.
static double contrast(Color a, Color b) { static double contrast(Color a, Color b) {
final la = a.computeLuminance(); final la = a.computeLuminance();
@ -108,3 +120,71 @@ class const TopGradient({
); );
} }
} }
class _TopGradientState() extends State<TopGradient> {
/// Whether the screen is the one in view: its tab showing, its route on
/// top.
var _inView = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_inView =
TabVisibility.of(context) &&
(ModalRoute.of(context)?.isCurrent ?? true);
_claim();
}
@override
void didUpdateWidget(TopGradient oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color) {
_claim();
}
}
/// Gives the frame this screen's colour while it is in view, after the
/// frame being built, as the frame is built above it.
void _claim() {
final tint = ScreenTintScope.maybeOf(context);
if (tint == null || !_inView) {
return;
}
final color = widget.color;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inView) {
tint.show(color);
}
});
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final top = TopGradient.topTone(scheme, widget.color, widget.background);
return ColoredBox(
color: widget.background,
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: MediaQuery.paddingOf(context).top + widget.extent,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [top, widget.background],
),
),
),
),
widget.child,
],
),
);
}
}

View file

@ -5,9 +5,11 @@ import 'package:flutter_bloc/flutter_bloc.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/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart'; import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.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/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/connect_assistant_cubit.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/connect_assistant_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/connected_assistant.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -32,44 +34,52 @@ class const _ConnectClaude() extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final state = context.watch<ConnectAssistantCubit>().state; final state = context.watch<ConnectAssistantCubit>().state;
final instance = DependenciesScope.of(context).serverName; final instance = DependenciesScope.of(context).serverName;
return Scaffold( return TopGradient(
backgroundColor: theme.colorScheme.surfaceContainerLow, color: Brand.primary,
appBar: AppBar(title: Text(l10n.assistantConnectTitle)), background: theme.colorScheme.surfaceContainerLow,
body: state.loading child: Scaffold(
? const Center(child: AppLoadingIndicator()) backgroundColor: Colors.transparent,
: Align( appBar: AppBar(
alignment: Alignment.topCenter, backgroundColor: Colors.transparent,
child: ConstrainedBox( systemOverlayStyle: TopGradient.overlayStyle(context),
constraints: const BoxConstraints( title: Text(l10n.assistantConnectTitle),
maxWidth: readableContentMaxWidth, ),
), body: state.loading
child: ListView( ? const Center(child: AppLoadingIndicator())
padding: const EdgeInsets.all(12), : Align(
children: [ alignment: Alignment.topCenter,
if (state.failed) child: ConstrainedBox(
_Banner( constraints: const BoxConstraints(
text: l10n.assistantUnreachable, maxWidth: readableContentMaxWidth,
action: TextButton( ),
onPressed: () => unawaited( child: ListView(
context.read<ConnectAssistantCubit>().load(), padding: const EdgeInsets.all(12),
children: [
if (state.failed)
_Banner(
text: l10n.assistantUnreachable,
action: TextButton(
onPressed: () => unawaited(
context.read<ConnectAssistantCubit>().load(),
),
child: Text(l10n.assistantRetry),
), ),
child: Text(l10n.assistantRetry),
), ),
SectionCard(
child: Text(l10n.assistantConnectIntro(instance)),
), ),
SectionCard(
child: Text(l10n.assistantConnectIntro(instance)),
),
const SizedBox(height: 12),
if (state.connectorUrl case final url?) ...[
_Steps(url: url, code: state.code),
const SizedBox(height: 12), const SizedBox(height: 12),
_Connections(connections: state.connections), if (state.connectorUrl case final url?) ...[
] else if (!state.failed) _Steps(url: url, code: state.code),
SectionCard(child: Text(l10n.assistantNotOffered)), const SizedBox(height: 12),
], _Connections(connections: state.connections),
] else if (!state.failed)
SectionCard(child: Text(l10n.assistantNotOffered)),
],
),
), ),
), ),
), ),
); );
} }
} }

View file

@ -7,6 +7,8 @@ import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart'; import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart'; import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/inbox_section.dart';
@ -25,21 +27,28 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
DependenciesScope.of(context).auth, DependenciesScope.of(context).auth,
PersonScope.of(context).studysets, PersonScope.of(context).studysets,
)..add(const GitHubSignInEventStarted()), )..add(const GitHubSignInEventStarted()),
child: Scaffold( child: TopGradient(
appBar: AppBar( color: Brand.primary,
title: Text(l10n.profileTitle), background: Theme.of(context).colorScheme.surface,
actions: [ child: Scaffold(
IconButton( backgroundColor: Colors.transparent,
icon: const SyncTroubleBadge( appBar: AppBar(
child: Icon(LucideIcons.settings), backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.profileTitle),
actions: [
IconButton(
icon: const SyncTroubleBadge(
child: Icon(LucideIcons.settings),
),
tooltip: l10n.profileSettingsTooltip,
onPressed: () => context.push(RoutePaths.settings),
), ),
tooltip: l10n.profileSettingsTooltip, const SizedBox(width: 4),
onPressed: () => context.push(RoutePaths.settings), ],
), ),
const SizedBox(width: 4), body: const _CancelWhenLeft(child: _Account()),
],
), ),
body: const _CancelWhenLeft(child: _Account()),
), ),
); );
} }

View file

@ -7,9 +7,11 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart'; import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/content_counts_line.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_summary_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_summary_sheet.dart';
@ -71,57 +73,66 @@ class _ExploreState() extends State<_Explore> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return Scaffold( return TopGradient(
appBar: AppBar(title: Text(l10n.exploreTitle)), color: Brand.primary,
body: BlocConsumer<ExploreBloc, ExploreState>( background: Theme.of(context).colorScheme.surface,
listenWhen: (before, now) => now.outcome != null, child: Scaffold(
listener: (context, state) { backgroundColor: Colors.transparent,
final messenger = ScaffoldMessenger.of(context) appBar: AppBar(
..hideCurrentSnackBar(); backgroundColor: Colors.transparent,
switch (state.outcome) { systemOverlayStyle: TopGradient.overlayStyle(context),
case ExploreAdded(:final studysetId): title: Text(l10n.exploreTitle),
messenger.showSnackBar( ),
SnackBar( body: BlocConsumer<ExploreBloc, ExploreState>(
content: Text(l10n.exploreAdded), listenWhen: (before, now) => now.outcome != null,
action: SnackBarAction( listener: (context, state) {
label: l10n.exploreOpen, final messenger = ScaffoldMessenger.of(context)
onPressed: () => ..hideCurrentSnackBar();
context.go(RoutePaths.studyset(studysetId)), switch (state.outcome) {
case ExploreAdded(:final studysetId):
messenger.showSnackBar(
SnackBar(
content: Text(l10n.exploreAdded),
action: SnackBarAction(
label: l10n.exploreOpen,
onPressed: () =>
context.go(RoutePaths.studyset(studysetId)),
),
), ),
), );
); case ExploreNeedsNewerApp():
case ExploreNeedsNewerApp(): messenger.showSnackBar(
messenger.showSnackBar( SnackBar(content: Text(l10n.exploreNeedsNewerApp)),
SnackBar(content: Text(l10n.exploreNeedsNewerApp)), );
); case ExploreFailed():
case ExploreFailed(): messenger.showSnackBar(
messenger.showSnackBar( SnackBar(content: Text(l10n.exploreFailed)),
SnackBar(content: Text(l10n.exploreFailed)), );
); case null:
case null: break;
break; }
} },
}, builder: (context, state) => Center(
builder: (context, state) => Center( child: ConstrainedBox(
child: ConstrainedBox( constraints: const BoxConstraints(
constraints: const BoxConstraints( maxWidth: readableContentMaxWidth,
maxWidth: readableContentMaxWidth, ),
), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Padding(
Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: SearchBar(
child: SearchBar( hintText: l10n.exploreSearch,
hintText: l10n.exploreSearch, leading: const Icon(LucideIcons.search),
leading: const Icon(LucideIcons.search), elevation: const WidgetStatePropertyAll(0),
elevation: const WidgetStatePropertyAll(0), onChanged: _search,
onChanged: _search, ),
), ),
), _Filters(filter: state.filter, onChanged: _filter),
_Filters(filter: state.filter, onChanged: _filter), Expanded(child: _Results(state: state)),
Expanded(child: _Results(state: state)), ],
], ),
), ),
), ),
), ),

View file

@ -88,6 +88,7 @@ class _HomeState() extends State<_Home> {
child: TopGradient( child: TopGradient(
color: Brand.primary, color: Brand.primary,
background: Theme.of(context).colorScheme.surface, background: Theme.of(context).colorScheme.surface,
extent: 360,
child: _scaffold(context, l10n, loaded, today), child: _scaffold(context, l10n, loaded, today),
), ),
); );

View file

@ -6,8 +6,10 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_setti
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart'; import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart'; import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_status_card.dart'; import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_status_card.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart'; import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_settings_card.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_settings_card.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
@ -27,82 +29,90 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return TopGradient(
backgroundColor: theme.colorScheme.surfaceContainerLow, color: Brand.primary,
appBar: AppBar(title: Text(l10n.settingsTitle)), background: theme.colorScheme.surfaceContainerLow,
body: Align( child: Scaffold(
alignment: Alignment.topCenter, backgroundColor: Colors.transparent,
child: ConstrainedBox( appBar: AppBar(
constraints: const BoxConstraints( backgroundColor: Colors.transparent,
maxWidth: readableContentMaxWidth, systemOverlayStyle: TopGradient.overlayStyle(context),
), title: Text(l10n.settingsTitle),
child: ListView( ),
padding: const EdgeInsets.all(12), body: Align(
children: [ alignment: Alignment.topCenter,
const SyncStatusCard(), child: ConstrainedBox(
const SizedBox(height: 12), constraints: const BoxConstraints(
const DeviceSettingsCard(), maxWidth: readableContentMaxWidth,
const SizedBox(height: 12), ),
const CitationSettingsCard(), child: ListView(
const SizedBox(height: 12), padding: const EdgeInsets.all(12),
const _StudyCard(), children: [
const SizedBox(height: 12), const SyncStatusCard(),
const AssistantSettingsCard(), const SizedBox(height: 12),
const SizedBox(height: 12), const DeviceSettingsCard(),
const _PrivacyCard(), const SizedBox(height: 12),
const SizedBox(height: 12), const CitationSettingsCard(),
SectionCard( const SizedBox(height: 12),
padding: const EdgeInsets.symmetric(vertical: 8), const _StudyCard(),
child: Column( const SizedBox(height: 12),
crossAxisAlignment: CrossAxisAlignment.start, const AssistantSettingsCard(),
children: [ const SizedBox(height: 12),
Padding( const _PrivacyCard(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), const SizedBox(height: 12),
child: Text( SectionCard(
l10n.settingsAccount, padding: const EdgeInsets.symmetric(vertical: 8),
style: theme.textTheme.titleMedium, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
l10n.settingsAccount,
style: theme.textTheme.titleMedium,
),
), ),
), // The accounts on this device, one per instance.
// The accounts on this device, one per instance. if (InstanceScope.maybeOf(context) case final scope?)
if (InstanceScope.maybeOf(context) case final scope?) ListTile(
leading: const Icon(LucideIcons.users),
title: Text(l10n.settingsSwitchAccount),
subtitle: Text(scope.choice.current?.name ?? ''),
onTap: () => showAccountSwitcher(context),
),
ListTile( ListTile(
leading: const Icon(LucideIcons.users), leading: const Icon(LucideIcons.logOut),
title: Text(l10n.settingsSwitchAccount), title: Text(l10n.settingsSignOut),
subtitle: Text(scope.choice.current?.name ?? ''), onTap: () => _signOut(context),
onTap: () => showAccountSwitcher(context),
), ),
ListTile( ListTile(
leading: const Icon(LucideIcons.logOut), leading: Icon(
title: Text(l10n.settingsSignOut), LucideIcons.trash2,
onTap: () => _signOut(context), color: theme.colorScheme.error,
), ),
ListTile( title: Text(
leading: Icon( l10n.settingsDeleteAccount,
LucideIcons.trash2, style: TextStyle(color: theme.colorScheme.error),
color: theme.colorScheme.error, ),
onTap: () => _delete(context),
), ),
title: Text( ],
l10n.settingsDeleteAccount, ),
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () => _delete(context),
),
],
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), SectionCard(
SectionCard( padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 8), child: ListTile(
child: ListTile( leading: const Icon(LucideIcons.info),
leading: const Icon(LucideIcons.info), title: Text(l10n.settingsAbout),
title: Text(l10n.settingsAbout), onTap: () =>
onTap: () => showAbout(context, DependenciesScope.of(context).about),
showAbout(context, DependenciesScope.of(context).about), ),
), ),
), const SizedBox(height: 24),
const SizedBox(height: 24), const _Version(),
const _Version(), ],
], ),
), ),
), ),
), ),

View file

@ -8,9 +8,11 @@ import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart'; 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/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/scroll_hiding_fab.dart'; import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
@ -158,78 +160,87 @@ class _LibraryState() extends State<_Library> {
below: create, below: create,
) )
: create, : create,
builder: (context, fab) => Scaffold( builder: (context, fab) => TopGradient(
// The cards stand out from it by their shadow. color: Brand.primary,
backgroundColor: theme.colorScheme.surfaceContainerLow, background: theme.colorScheme.surfaceContainerLow,
appBar: AppBar( child: Scaffold(
title: Text(l10n.libraryTitle), // The cards stand out from it by their shadow.
actions: [ backgroundColor: Colors.transparent,
IconButton( appBar: AppBar(
tooltip: l10n.librarySearchTooltip, backgroundColor: Colors.transparent,
// Tinted while the search narrows what the grid shows. systemOverlayStyle: TopGradient.overlayStyle(context),
icon: Icon( title: Text(l10n.libraryTitle),
LucideIcons.search, actions: [
color: _searching ? theme.colorScheme.primary : null, IconButton(
), tooltip: l10n.librarySearchTooltip,
onPressed: _toggleSearch, // Tinted while the search narrows what the grid shows.
), icon: Icon(
IconButton( LucideIcons.search,
tooltip: l10n.libraryImport, color: _searching ? theme.colorScheme.primary : null,
icon: const Icon(LucideIcons.fileInput),
onPressed: () => unawaited(_import(context)),
),
IconButton(
tooltip: l10n.libraryRefresh,
icon: const Icon(LucideIcons.refreshCw),
onPressed: () {
context.read<SyncStatusBloc>().add(
const SyncStatusEventSyncRequested(),
);
unawaited(context.read<PublishedCubit>().refresh());
},
),
if (wide)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FilledButton.tonalIcon(
onPressed: () => _create(context),
icon: const Icon(LucideIcons.plus),
label: Text(l10n.libraryCreateStudyset),
), ),
onPressed: _toggleSearch,
), ),
], IconButton(
), tooltip: l10n.libraryImport,
floatingActionButton: fab, icon: const Icon(LucideIcons.fileInput),
body: Column( onPressed: () => unawaited(_import(context)),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_searching)
_SearchRow(
controller: _query,
focusNode: _queryFocus,
onChanged: () => setState(() {}),
onClose: _toggleSearch,
), ),
Expanded( IconButton(
child: BlocBuilder<StudysetListBloc, StudysetListState>( tooltip: l10n.libraryRefresh,
builder: (context, state) => switch (state) { icon: const Icon(LucideIcons.refreshCw),
StudysetListStateLoading() => const Center( onPressed: () {
child: AppLoadingIndicator(), context.read<SyncStatusBloc>().add(
), const SyncStatusEventSyncRequested(),
StudysetListStateFailed() => const _Failed(), );
StudysetListStateLoaded(studysets: []) => _Message( unawaited(context.read<PublishedCubit>().refresh());
l10n.libraryEmpty,
),
StudysetListStateLoaded(:final studysets, :final progress) =>
_Grid(
studysets: studysets,
progress: progress,
query: _query.text,
),
}, },
), ),
), if (wide)
], Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FilledButton.tonalIcon(
onPressed: () => _create(context),
icon: const Icon(LucideIcons.plus),
label: Text(l10n.libraryCreateStudyset),
),
),
],
),
floatingActionButton: fab,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_searching)
_SearchRow(
controller: _query,
focusNode: _queryFocus,
onChanged: () => setState(() {}),
onClose: _toggleSearch,
),
Expanded(
child: BlocBuilder<StudysetListBloc, StudysetListState>(
builder: (context, state) => switch (state) {
StudysetListStateLoading() => const Center(
child: AppLoadingIndicator(),
),
StudysetListStateFailed() => const _Failed(),
StudysetListStateLoaded(studysets: []) => _Message(
l10n.libraryEmpty,
),
StudysetListStateLoaded(
:final studysets,
:final progress,
) =>
_Grid(
studysets: studysets,
progress: progress,
query: _query.text,
),
},
),
),
],
),
), ),
), ),
); );

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
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';
@ -292,18 +293,35 @@ class _StudysetViewState() extends State<_StudysetView> {
builder: (context, fab) => TopGradient( builder: (context, fab) => TopGradient(
color: color, color: color,
background: theme.colorScheme.surfaceContainerLow, background: theme.colorScheme.surfaceContainerLow,
child: _scaffold(context, fab, rail, content, description), extent: 280,
// The space the screen gets, which beside the rail is less than the
// window's.
child: LayoutBuilder(
builder: (context, constraints) => _scaffold(
context,
fab,
rail,
content,
description,
roomy: constraints.maxWidth >= _roomyWidth,
),
),
), ),
); );
} }
/// How wide the screen has to be for Add material to show its label in
/// the app bar; below, it is an icon, so Study, Share and the options fit.
static const _roomyWidth = 960.0;
Widget _scaffold( Widget _scaffold(
BuildContext context, BuildContext context,
Widget? fab, Widget? fab,
Widget rail, Widget rail,
Widget content, Widget content,
Widget? description, Widget? description, {
) { required bool roomy,
}) {
final studyset = widget.studyset; final studyset = widget.studyset;
final all = widget.all; final all = widget.all;
final theme = Theme.of(context); final theme = Theme.of(context);
@ -340,16 +358,25 @@ class _StudysetViewState() extends State<_StudysetView> {
child: _AddMaterial( child: _AddMaterial(
studysetId: studyset.id, studysetId: studyset.id,
controller: _addMaterial, controller: _addMaterial,
builder: (context, menu) => FilledButton.tonalIcon( builder: (context, menu) => roomy
style: FilledButton.styleFrom( ? FilledButton.tonalIcon(
minimumSize: const Size(0, 48), style: FilledButton.styleFrom(
), minimumSize: const Size(0, 48),
onPressed: () => menu.isOpen ? menu.close() : menu.open(), ),
icon: const Icon(LucideIcons.plus), onPressed: () =>
label: Text( menu.isOpen ? menu.close() : menu.open(),
AppLocalizations.of(context).studysetAddMaterial, icon: const Icon(LucideIcons.plus),
), label: Text(
), AppLocalizations.of(context).studysetAddMaterial,
),
)
: IconButton.filledTonal(
tooltip: AppLocalizations.of(context)
.studysetAddMaterial,
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
),
), ),
), ),
], ],
@ -954,13 +981,27 @@ class _DescriptionState() extends State<_Description> {
_measureAfterFrame(); _measureAfterFrame();
if (_overflows != true) { if (_overflows != true) {
// Measured again whenever it changes size, as when the editor's view // Measured again whenever it changes size, as when the editor's view
// has laid its document out. // has laid its document out. Laid out whole but shown no taller than
// the preview until measured, so a long one never takes more room
// for a frame than the screen has.
return NotificationListener<SizeChangedLayoutNotification>( return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) { onNotification: (_) {
_measureAfterFrame(); _measureAfterFrame();
return true; return true;
}, },
child: SizeChangedLayoutNotifier(key: _measured, child: view), child: ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: _Description.maxPreviewHeight,
),
child: OverflowBox(
alignment: Alignment.topLeft,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
child: SizeChangedLayoutNotifier(key: _measured, child: view),
),
),
),
); );
} }
return Column( return Column(

View file

@ -2,6 +2,10 @@ import 'package:flutter/services.dart';
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/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
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/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';
@ -59,6 +63,54 @@ void main() {
expect(find.byType(NavigationBar), findsNothing); expect(find.byType(NavigationBar), findsNothing);
}); });
testWidgets("from medium windows on, the rail sits on the screen's "
'colour, and the screen is a panel beside it', (tester) async {
await showApp(tester);
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
expect(rail.backgroundColor, Colors.transparent);
expect(find.byType(VerticalDivider), findsNothing);
// Right beside the rail, inside the panel's edge.
expect(
tester.getTopLeft(find.byType(TopGradient)).dx,
closeTo(tester.getTopRight(find.byType(NavigationRail)).dx, 1),
);
expect(tester.getTopLeft(find.byType(TopGradient)).dy, greaterThan(0));
});
testWidgets("the frame takes a coloured studyset's colour, and cobalt "
'again back on Home', (tester) async {
studysets.receiveRemoteChange(
'latin',
encodeStudysetPayload(
StudysetContent(
title: 'Latin',
description: RichContent.empty(),
colorName: 'cerise',
),
),
);
await showApp(tester);
Color tint() => tester
.widget<ScreenTintScope>(find.byType(ScreenTintScope))
.notifier!
.color;
expect(tint(), Brand.primary);
await openTab(tester, 'Library');
await tester.tap(find.text('Latin'));
await tester.pumpAndSettle();
expect(tint(), PaletteColor.cerise.background);
await openTab(tester, 'Home');
expect(tint(), Brand.primary);
await openTab(tester, 'Library');
expect(tint(), PaletteColor.cerise.background);
await tester.pageBack();
await tester.pumpAndSettle();
expect(tint(), Brand.primary);
});
testWidgets('shows a navigation bar below medium windows', (tester) async { testWidgets('shows a navigation bar below medium windows', (tester) async {
await showApp(tester, width: 599); await showApp(tester, width: 599);

View file

@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
void main() {
for (final location in [
RoutePaths.home,
RoutePaths.library,
RoutePaths.explore,
RoutePaths.profile,
RoutePaths.settings,
RoutePaths.connectAssistant,
]) {
testWidgets('cobalt washes down from the top of $location, behind a '
'clear app bar', (tester) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(tester, testDependencies(), location: location);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
final gradient = tester.widget<TopGradient>(find.byType(TopGradient));
expect(gradient.color, Brand.primary);
expect(
tester.widget<AppBar>(find.byType(AppBar)).backgroundColor,
Colors.transparent,
);
});
}
}

View file

@ -28,6 +28,25 @@ void main() {
} }
} }
test("the frame around the tabs keeps the rail's labels readable on "
'every palette colour, top and foot', () {
for (final brightness in Brightness.values) {
final scheme = appTheme(brightness).colorScheme;
for (final color in PaletteColor.values) {
for (final tone in TopGradient.frame(
scheme,
color.background,
).colors) {
expect(
TopGradient.contrast(tone, scheme.onSurfaceVariant),
greaterThanOrEqualTo(TopGradient.minContrast),
reason: '${brightness.name} ${color.name}',
);
}
}
}
});
test('is a deep tone of cobalt in dark mode, a tint in light mode', () { test('is a deep tone of cobalt in dark mode, a tint in light mode', () {
final dark = appTheme(Brightness.dark).colorScheme; final dark = appTheme(Brightness.dark).colorScheme;
final light = appTheme(Brightness.light).colorScheme; final light = appTheme(Brightness.light).colorScheme;

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset
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/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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/studyset_screen.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../../support/fake_auth_gateway.dart'; import '../../support/fake_auth_gateway.dart';
@ -272,7 +273,7 @@ void main() {
await tester.tap(find.text('Open')); await tester.tap(find.text('Open'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Japanese'), findsWidgets); expect(find.text('Japanese'), findsWidgets);
expect(find.text('Add material'), findsOneWidget); expect(find.byType(StudysetScreen), findsOneWidget);
}); });
testWidgets('a studyset the person has a copy of is in their library ' testWidgets('a studyset the person has a copy of is in their library '

View file

@ -208,9 +208,13 @@ void main() {
for (final part in ['Steps', 'Add step', 'Read']) { for (final part in ['Steps', 'Add step', 'Read']) {
expect(inCard(1, find.text(part)), findsOneWidget, reason: part); expect(inCard(1, find.text(part)), findsOneWidget, reason: part);
} }
// 12 above the screen's foot (inside the shell's panel).
final screen = find
.ancestor(of: cards.at(1), matching: find.byType(Scaffold))
.first;
expect( expect(
tester.getRect(cards.at(1)).bottom, tester.getRect(cards.at(1)).bottom,
moreOrLessEquals(1000 - 12, epsilon: 1), moreOrLessEquals(tester.getRect(screen).bottom - 12, epsilon: 1),
); );
}); });

View file

@ -161,6 +161,19 @@ void main() {
); );
} }
testWidgets('beside the rail on a narrow desktop window, Add material is '
'an icon, so the app bar fits', (tester) async {
stored('japanese', 'Japanese');
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: 900,
);
expect(find.byTooltip('Add material'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Add material'), findsNothing);
});
group('Its colour washes down from the top', () { group('Its colour washes down from the top', () {
Color wash(WidgetTester tester) => tester Color wash(WidgetTester tester) => tester
.widget<TopGradient>( .widget<TopGradient>(