feat(theme): a studyset's colour on all its screens, Vaia's deeper shades

Every screen of a studyset now washes its colour (its own or the nearest
above it), as Vaia does: studying it, its cards, writing a card, its
notes, its paths and steps, its dictionary. The router hands the colour
down from where it resolves the studyset (ScreenColor), so a screen's
TopGradient without a colour of its own takes it.

The dark tone is now a deep shade of the colour, 42 % of it over black
(cerise a maroon like Vaia's #4e0b23, cobalt a navy), instead of a mix
into the background; light mode keeps its tint. The wash reaches about
as far as the header (160 dp; a studyset 180, Home 360). From medium
windows on, the frame fades into the background 60 % of the way down,
and the wash inside its panel is half as strong, as the frame carries
the colour; screens over the tabs (studying, the card browser and
composer, Settings and Connect Claude) sit in the same frame and panel,
without the rail (TintFrame, FramedScreen). The study session's header
lies on the colour with an edge below it, its status bar styled to suit.

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 14:01:12 +02:00
commit 5f8fade41c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 972 additions and 615 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 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). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `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

@ -5,7 +5,11 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart'; import 'package:halkyon_learn_flutter/src/core/router/app_shell.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/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart'; import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
import 'package:halkyon_learn_flutter/src/core/widget/screen_tint.dart';
import 'package:halkyon_learn_flutter/src/core/widget/tinted_frame.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/widget/connect_claude_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
@ -30,6 +34,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
@ -168,20 +173,22 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
return _inStudyset( return _inStudyset(
context, context,
studysetId, studysetId,
(studyset, _) => BlocProvider( (studyset, _) => FramedScreen(
create: (context) => StudySessionBloc( child: BlocProvider(
person.studysets, create: (context) => StudySessionBloc(
person.cards, person.studysets,
person.cardDeletions, person.cards,
person.reviews, person.cardDeletions,
person.tags, person.reviews,
person.settings, person.tags,
studysetId, person.settings,
focus: StudyFocus.fromQuery(state.uri.queryParameters), studysetId,
)..add(const StudySessionEventStarted()), focus: StudyFocus.fromQuery(state.uri.queryParameters),
child: StudyScreen( )..add(const StudySessionEventStarted()),
studysetId: studysetId, child: StudyScreen(
title: studyset.content.title, studysetId: studysetId,
title: studyset.content.title,
),
), ),
), ),
); );
@ -194,10 +201,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
builder: (context, state) => _inStudyset( builder: (context, state) => _inStudyset(
context, context,
state.pathParameters['studysetId']!, state.pathParameters['studysetId']!,
(studyset, all) => CardBrowserScreen( (studyset, all) => FramedScreen(
studyset: studyset, child: CardBrowserScreen(
all: all, studyset: studyset,
subtree: state.uri.queryParameters['subtree'] == '1', all: all,
subtree: state.uri.queryParameters['subtree'] == '1',
),
), ),
), ),
), ),
@ -231,11 +240,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
), ),
GoRoute( GoRoute(
path: RoutePaths.settings, path: RoutePaths.settings,
builder: (context, state) => const SettingsScreen(), builder: (context, state) => const FramedScreen(child: SettingsScreen()),
routes: [ routes: [
GoRoute( GoRoute(
path: 'assistant', path: 'assistant',
builder: (context, state) => const ConnectClaudeScreen(), builder: (context, state) =>
const FramedScreen(child: ConnectClaudeScreen()),
), ),
], ],
), ),
@ -325,7 +335,14 @@ Widget _inStudyset(
builder: (studyset, all) => StudysetLanguageScope( builder: (studyset, all) => StudysetLanguageScope(
studyset: studyset, studyset: studyset,
all: all, all: all,
child: builder(studyset, all), // Every screen of a studyset washes its colour, its own or the nearest
// above it.
child: ScreenColor(
color:
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary,
child: builder(studyset, all),
),
), ),
); );
@ -353,13 +370,15 @@ Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
return _inStudyset( return _inStudyset(
context, context,
studysetId, studysetId,
(studyset, _) => BlocProvider( (studyset, _) => FramedScreen(
create: (context) => child: BlocProvider(
CardComposerBloc(person.cards, person.tags, studysetId) create: (context) =>
..add(const CardComposerEventStarted()), CardComposerBloc(person.cards, person.tags, studysetId)
child: CardComposerScreen( ..add(const CardComposerEventStarted()),
studysetTitle: studyset.content.title, child: CardComposerScreen(
initialCardId: cardId, studysetTitle: studyset.content.title,
initialCardId: cardId,
),
), ),
), ),
); );

View file

@ -3,7 +3,7 @@ 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/screen_tint.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart'; import 'package:halkyon_learn_flutter/src/core/widget/tinted_frame.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';
@ -58,48 +58,26 @@ class _AppShellState() extends State<AppShell> {
) )
: Icon(data); : Icon(data);
if (context.windowSize.isAtLeastMedium) { if (context.windowSize.isAtLeastMedium) {
final scheme = Theme.of(context).colorScheme;
return ScreenTintScope( return ScreenTintScope(
notifier: _tint, notifier: _tint,
child: Scaffold( child: TintFrame(
backgroundColor: scheme.surface, tint: _tint,
body: ListenableBuilder( rail: NavigationRail(
listenable: _tint, backgroundColor: Colors.transparent,
builder: (context, child) => TweenAnimationBuilder<Color?>( selectedIndex: navigationShell.currentIndex,
tween: ColorTween(end: _tint.color), labelType: NavigationRailLabelType.all,
duration: const Duration(milliseconds: 250), onDestinationSelected: _select,
builder: (context, color, child) => DecoratedBox( leading: const _RailLogo(),
decoration: BoxDecoration( destinations: [
gradient: TopGradient.frame(scheme, color ?? _tint.color), for (final destination in destinations)
NavigationRailDestination(
icon: icon(destination, destination.icon),
selectedIcon: icon(destination, destination.selectedIcon),
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)),
],
),
), ),
child: navigationShell,
), ),
); );
} }
@ -132,47 +110,6 @@ class _AppShellState() extends State<AppShell> {
} }
} }
/// 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,
),
),
);
}
}
/// The kingfisher atop the rail. The icon has a white background, so it sits /// The kingfisher atop the rail. The icon has a white background, so it sits
/// in a rounded tile that reads on light and dark rails alike. /// in a rounded tile that reads on light and dark rails alike.
class const _RailLogo() extends StatelessWidget { class const _RailLogo() extends StatelessWidget {

View file

@ -32,3 +32,22 @@ class const ScreenTintScope({
static ScreenTint? maybeOf(BuildContext context) => static ScreenTint? maybeOf(BuildContext context) =>
context.getInheritedWidgetOfExactType<ScreenTintScope>()?.notifier; context.getInheritedWidgetOfExactType<ScreenTintScope>()?.notifier;
} }
/// The colour of the screens below it: a studyset's own, or the nearest one
/// above it, for every screen of it (its cards, notes, paths, dictionary,
/// studying them), as Vaia carries a studyset's colour (decided 2026-10-10);
/// cobalt without.
class const ScreenColor({
/// The colour.
required final Color color,
required super.child,
super.key,
}) extends InheritedWidget {
/// The colour above [context]; cobalt where none is.
static Color of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ScreenColor>()?.color ??
Brand.primary;
@override
bool updateShouldNotify(ScreenColor oldWidget) => color != oldWidget.color;
}

View file

@ -0,0 +1,121 @@
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:material_ui/material_ui.dart';
/// The frame around a screen from medium windows on, as Vaia lays it out
/// (decided 2026-10-10): the window takes [tint]'s colour, fading out down
/// the way ([TopGradient.frame]); [rail], if any, sits on it; and the screen
/// is a rounded panel inset beside it. A colour change fades in.
class const TintFrame({
/// The colour of the screen in view.
required final ScreenTint tint,
/// The screen.
required final Widget child,
/// The navigation rail, for the tabs; none for a screen over them.
final Widget? rail,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final rail = this.rail;
return Scaffold(
backgroundColor: scheme.surface,
body: ListenableBuilder(
listenable: tint,
builder: (context, child) => TweenAnimationBuilder<Color?>(
tween: ColorTween(end: tint.color),
duration: const Duration(milliseconds: 250),
builder: (context, color, child) => DecoratedBox(
decoration: BoxDecoration(
gradient: TopGradient.frame(scheme, color ?? tint.color),
),
child: child,
),
child: child,
),
child: Row(
children: [
?rail,
Expanded(
child: _Panel(alone: rail == null, child: child),
),
],
),
),
);
}
}
/// A screen over the tabs (studying, a studyset's cards, writing a card,
/// Settings), framed from medium windows on as the tabs' screens are, without
/// the rail; as it is below that.
class const FramedScreen({required final Widget child, super.key})
extends StatefulWidget {
@override
State<FramedScreen> createState() => _FramedScreenState();
}
class _FramedScreenState() extends State<FramedScreen> {
final _tint = ScreenTint();
@override
void dispose() {
_tint.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => context.windowSize.isAtLeastMedium
? ScreenTintScope(
notifier: _tint,
child: TintFrame(tint: _tint, child: widget.child),
)
: widget.child;
}
/// The screen: a rounded panel inset on the frame, beside the rail or
/// [alone], 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, required final bool alone})
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(
alone ? insets.left + _gap : 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,
removeLeft: alone,
removeTop: true,
removeRight: true,
removeBottom: true,
child: child,
),
),
);
}
}

View file

@ -1,27 +1,33 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.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/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/screen_tint.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 the tabs' screens and Settings, a studyset's own colour on its /// cobalt on the tabs' screens and Settings, a studyset's colour on its
/// screen (decided 2026-10-10, as Vaia does). While the screen is the one in /// screen and every screen of it ([ScreenColor]), as Vaia does (decided
/// view (its tab showing, its route on top), it gives the frame around the /// 2026-10-10). While the screen is the one in view (its tab showing, its
/// tabs its colour too ([ScreenTint]): the navigation rail takes it. /// route on top), it gives the frame around it 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 in dark mode as a deep shade of [color] (42 % of it over
/// mode (a deep tone, cobalt a navy) and 35 % in light mode (a pastel tint), /// black, as Vaia's: cerise a maroon, cobalt a navy), in light mode as a
/// less where the theme's text on it would fall below WCAG AA (4.5:1), so /// pastel tint (35 % of it over the background); less where the theme's
/// the app bar reads on every palette colour. It fades out over [extent] /// text on it would fall below WCAG AA (4.5:1), so the app bar reads on
/// below the status bar and stays put while the screen's content scrolls. /// every palette colour. It fades out over [extent] below the status bar
/// and stays put while the screen's content scrolls. Inside the frame's
/// panel, where the frame already carries the colour, it is half as
/// strong.
/// ///
/// The screen's Scaffold and AppBar are transparent on top of it, the app /// The screen's Scaffold and AppBar are transparent on top of it, the app
/// bar with [overlayStyle], since a transparent bar can't tell the status /// bar with [overlayStyle], since a transparent bar can't tell the status
/// bar's icons which tone they sit on. Content laid out below the app bar /// bar's icons which tone they sit on. Content laid out below the app bar
/// (the Scaffold's default) never scrolls under its title. /// (the Scaffold's default) never scrolls under its title.
class const TopGradient({ class const TopGradient({
/// The colour washed in. /// The colour washed in; the [ScreenColor] above without.
required final Color color, final Color? color,
/// What the wash fades into: the screen's background. /// What the wash fades into: the screen's background.
required final Color background, required final Color background,
@ -29,13 +35,13 @@ 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: far on Home and a /// How far below the status bar the wash reaches: far on Home, about as
/// studyset, as far as the header elsewhere. /// far as the header elsewhere.
final double extent = 200, final double extent = 160,
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
/// The share of [color] at the top in dark mode. /// The share of [color] over black at the top in dark mode.
static const darkMix = 0.6; static const darkShade = 0.42;
/// 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;
@ -43,8 +49,12 @@ class const TopGradient({
/// The contrast text keeps on a 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. /// How far down the frame fades into the background, as a share of its
static const frameFootShare = 0.4; /// height.
static const frameFade = 0.6;
/// How much of its strength the wash keeps inside the frame's panel.
static const panelShare = 0.5;
@override @override
State<TopGradient> createState() => _TopGradientState(); State<TopGradient> createState() => _TopGradientState();
@ -61,15 +71,14 @@ class const TopGradient({
double share = 1, double share = 1,
}) { }) {
final on = text ?? scheme.onSurface; final on = text ?? scheme.onSurface;
final strongest = final dark = scheme.brightness == Brightness.dark;
share * // In dark mode a shade of the colour, faded into the background by
switch (scheme.brightness) { // [share]; in light mode a tint of it over the background.
Brightness.dark => darkMix, final full = dark
Brightness.light => lightMix, ? Color.lerp(const Color(0xFF000000), color, darkShade)!
}; : Color.lerp(background, color, lightMix)!;
for (var step = 0; step <= 20; step++) { for (var step = 0; step <= 20; step++) {
final mix = strongest * (1 - step / 20); final tone = Color.lerp(background, full, share * (1 - step / 20))!;
final tone = Color.lerp(background, color, mix)!;
if (contrast(tone, on) >= minContrast) { if (contrast(tone, on) >= minContrast) {
return tone; return tone;
} }
@ -77,23 +86,18 @@ class const TopGradient({
return background; return background;
} }
/// The frame around the tabs from medium windows on (the navigation rail, /// The frame around a panel from medium windows on (the navigation rail,
/// the margin around the content panel): [color] from the top down, as /// the margin around the panel): [color] from the top, as strong as the
/// strong as the rail's labels (`onSurfaceVariant`) allow, weaker towards /// rail's labels (`onSurfaceVariant`) allow, faded into the background
/// the foot, where it keeps [frameFootShare] of it. /// [frameFade] of the way down.
static LinearGradient frame(ColorScheme scheme, Color color) => static LinearGradient frame(ColorScheme scheme, Color color) =>
LinearGradient( LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
stops: const [0, frameFade],
colors: [ colors: [
topTone(scheme, color, scheme.surface, text: scheme.onSurfaceVariant), topTone(scheme, color, scheme.surface, text: scheme.onSurfaceVariant),
topTone( scheme.surface,
scheme,
color,
scheme.surface,
text: scheme.onSurfaceVariant,
share: frameFootShare,
),
], ],
); );
@ -126,12 +130,16 @@ class _TopGradientState() extends State<TopGradient> {
/// top. /// top.
var _inView = false; var _inView = false;
/// The colour washed in: the widget's, else the screen's.
var _color = Brand.primary;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_inView = _inView =
TabVisibility.of(context) && TabVisibility.of(context) &&
(ModalRoute.of(context)?.isCurrent ?? true); (ModalRoute.of(context)?.isCurrent ?? true);
_color = widget.color ?? ScreenColor.of(context);
_claim(); _claim();
} }
@ -139,6 +147,7 @@ class _TopGradientState() extends State<TopGradient> {
void didUpdateWidget(TopGradient oldWidget) { void didUpdateWidget(TopGradient oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color) { if (oldWidget.color != widget.color) {
_color = widget.color ?? ScreenColor.of(context);
_claim(); _claim();
} }
} }
@ -150,7 +159,7 @@ class _TopGradientState() extends State<TopGradient> {
if (tint == null || !_inView) { if (tint == null || !_inView) {
return; return;
} }
final color = widget.color; final color = _color;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _inView) { if (mounted && _inView) {
tint.show(color); tint.show(color);
@ -161,7 +170,16 @@ class _TopGradientState() extends State<TopGradient> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final top = TopGradient.topTone(scheme, widget.color, widget.background); // In the frame's panel, the frame carries the colour already.
final inPanel =
ScreenTintScope.maybeOf(context) != null &&
context.windowSize.isAtLeastMedium;
final top = TopGradient.topTone(
scheme,
_color,
widget.background,
share: inPanel ? TopGradient.panelShare : 1,
);
return ColoredBox( return ColoredBox(
color: widget.background, color: widget.background,
child: Stack( child: Stack(

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart'; import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.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/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/card/bloc/card_browser_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
@ -385,75 +386,81 @@ class _BrowserState() extends State<_Browser> {
], ],
), ),
}; };
return Scaffold( return TopGradient(
appBar: AppBar( background: Theme.of(context).colorScheme.surface,
title: Text( child: Scaffold(
widget.subtree ? l10n.cardBrowserTitleSubtree(base) : base, backgroundColor: Colors.transparent,
overflow: TextOverflow.ellipsis, appBar: AppBar(
), backgroundColor: Colors.transparent,
actions: [ systemOverlayStyle: TopGradient.overlayStyle(context),
if (!widget.subtree) ...[ title: Text(
// Words where there is room for them; on a phone, icons. widget.subtree ? l10n.cardBrowserTitleSubtree(base) : base,
if (context.windowSize.isAtLeastMedium) ...[ overflow: TextOverflow.ellipsis,
FilledButton.icon( ),
onPressed: study, actions: [
icon: const Icon(LucideIcons.play, size: 18), if (!widget.subtree) ...[
label: Text(l10n.cardBrowserStudy), // Words where there is room for them; on a phone, icons.
), if (context.windowSize.isAtLeastMedium) ...[
const SizedBox(width: 8), FilledButton.icon(
TextButton.icon( onPressed: study,
onPressed: write, icon: const Icon(LucideIcons.play, size: 18),
icon: const Icon(LucideIcons.squarePen, size: 18), label: Text(l10n.cardBrowserStudy),
label: Text(l10n.cardBrowserEditor),
),
] else ...[
IconButton.filled(
onPressed: study,
icon: const Icon(LucideIcons.play),
tooltip: l10n.cardBrowserStudy,
),
IconButton(
onPressed: write,
icon: const Icon(LucideIcons.squarePen),
tooltip: l10n.cardBrowserEditor,
),
],
PopupMenuButton<_Menu>(
icon: const Icon(LucideIcons.ellipsisVertical),
tooltip: l10n.cardBrowserMore,
onSelected: (item) => switch (item) {
_Menu.import => unawaited(_import()),
_Menu.tags => unawaited(showManageTagsSheet(context)),
},
itemBuilder: (context) => [
PopupMenuItem(
value: _Menu.import,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
),
), ),
PopupMenuItem( const SizedBox(width: 8),
value: _Menu.tags, TextButton.icon(
child: ListTile( onPressed: write,
dense: true, icon: const Icon(LucideIcons.squarePen, size: 18),
contentPadding: EdgeInsets.zero, label: Text(l10n.cardBrowserEditor),
leading: const Icon(LucideIcons.tags), ),
title: Text(l10n.tagPickerManage), ] else ...[
), IconButton.filled(
onPressed: study,
icon: const Icon(LucideIcons.play),
tooltip: l10n.cardBrowserStudy,
),
IconButton(
onPressed: write,
icon: const Icon(LucideIcons.squarePen),
tooltip: l10n.cardBrowserEditor,
), ),
], ],
), PopupMenuButton<_Menu>(
icon: const Icon(LucideIcons.ellipsisVertical),
tooltip: l10n.cardBrowserMore,
onSelected: (item) => switch (item) {
_Menu.import => unawaited(_import()),
_Menu.tags => unawaited(showManageTagsSheet(context)),
},
itemBuilder: (context) => [
PopupMenuItem(
value: _Menu.import,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
),
),
PopupMenuItem(
value: _Menu.tags,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.tags),
title: Text(l10n.tagPickerManage),
),
),
],
),
],
const SizedBox(width: 4),
], ],
const SizedBox(width: 4), ),
], body: body,
bottomNavigationBar: state.selecting && !expanded
? _SelectionBar(actions: _actions(state))
: null,
), ),
body: body,
bottomNavigationBar: state.selecting && !expanded
? _SelectionBar(actions: _actions(state))
: null,
); );
}, },
); );

View file

@ -4,6 +4,7 @@ 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/layout/window_size.dart'; import 'package:halkyon_learn_flutter/src/core/layout/window_size.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/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.dart';
@ -201,88 +202,98 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
unawaited(_close()); unawaited(_close());
} }
}, },
child: Scaffold( child: TopGradient(
appBar: AppBar( background: Theme.of(context).colorScheme.surface,
leading: BackButton(onPressed: _close), child: Scaffold(
title: Column( backgroundColor: Colors.transparent,
crossAxisAlignment: CrossAxisAlignment.start, appBar: AppBar(
mainAxisSize: MainAxisSize.min, backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
leading: BackButton(onPressed: _close),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// By how it opened: to write new cards, or on a stored one
// (decided 2026-10-02); it holds both either way.
Text(
switch ((widget.initialCardId, widget.studysetTitle)) {
(null, '') => l10n.composerTitle,
(null, final title) => l10n.composerTitleFor(title),
(_, '') => l10n.composerEditTitle,
(_, final title) => l10n.composerEditTitleFor(title),
},
overflow: TextOverflow.ellipsis,
),
if (switch (state.saving) {
CardComposerSaving.idle => null,
CardComposerSaving.saving => l10n.composerSaving,
CardComposerSaving.failed => l10n.composerSaveFailed,
CardComposerSaving.saved => l10n.composerAllSaved,
}
case final status?)
Text(
status,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// By how it opened: to write new cards, or on a stored one SizedBox(
// (decided 2026-10-02); it holds both either way. width: expanded ? _stackWidth : _collapsedStackWidth,
Text( child: _Stack(
switch ((widget.initialCardId, widget.studysetTitle)) { state: state,
(null, '') => l10n.composerTitle, selected: _selected,
(null, final title) => l10n.composerTitleFor(title), expanded: expanded,
(_, '') => l10n.composerEditTitle, onAdd: _addDraft,
(_, final title) => l10n.composerEditTitleFor(title), onSelect: _select,
}, onToggle: () =>
overflow: TextOverflow.ellipsis, setState(() => _stackExpanded = !expanded),
),
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
// The person leaving the pane leaves the card, which saves
// it; moving between its fields doesn't.
child: Focus(
skipTraversal: true,
onFocusChange: (focused) {
if (!focused) {
_leaveSelected();
}
},
child: selected == null
? Center(child: Text(l10n.composerEmptyPane))
: CardDraftEditor(
key: ValueKey(selected.id),
draft: selected,
pins: state.pins,
tagging: state.tagging,
focusFront: _focusFront,
onDuplicate: () => _duplicate(selected),
onDelete: () => _delete(selected),
onSuggest: fromRelease(selected.content.published)
? () => suggestToAuthor(
context,
studysetId: context
.read<CardComposerBloc>()
.studysetId,
send: (feedback, body) =>
feedback.suggestCard(
selected.content.normalized,
body: body,
),
)
: null,
),
),
), ),
if (switch (state.saving) {
CardComposerSaving.idle => null,
CardComposerSaving.saving => l10n.composerSaving,
CardComposerSaving.failed => l10n.composerSaveFailed,
CardComposerSaving.saved => l10n.composerAllSaved,
}
case final status?)
Text(status, style: Theme.of(context).textTheme.bodySmall),
], ],
), ),
), ),
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: expanded ? _stackWidth : _collapsedStackWidth,
child: _Stack(
state: state,
selected: _selected,
expanded: expanded,
onAdd: _addDraft,
onSelect: _select,
onToggle: () => setState(() => _stackExpanded = !expanded),
),
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
// The person leaving the pane leaves the card, which saves
// it; moving between its fields doesn't.
child: Focus(
skipTraversal: true,
onFocusChange: (focused) {
if (!focused) {
_leaveSelected();
}
},
child: selected == null
? Center(child: Text(l10n.composerEmptyPane))
: CardDraftEditor(
key: ValueKey(selected.id),
draft: selected,
pins: state.pins,
tagging: state.tagging,
focusFront: _focusFront,
onDuplicate: () => _duplicate(selected),
onDelete: () => _delete(selected),
onSuggest: fromRelease(selected.content.published)
? () => suggestToAuthor(
context,
studysetId: context
.read<CardComposerBloc>()
.studysetId,
send: (feedback, body) =>
feedback.suggestCard(
selected.content.normalized,
body: body,
),
)
: null,
),
),
),
],
),
), ),
); );
}, },

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_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/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/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/dictionary_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/dictionary_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
@ -26,21 +27,29 @@ class const DictionaryScreen({required final String studysetId, super.key})
extends StatelessWidget { extends StatelessWidget {
@override @override
Widget build(BuildContext context) => StudysetDictionary( Widget build(BuildContext context) => StudysetDictionary(
missing: (context, status, retry) => Scaffold( missing: (context, status, retry) => TopGradient(
appBar: AppBar(title: Text(AppLocalizations.of(context).dictionaryTitle)), background: Theme.of(context).colorScheme.surface,
body: Center( child: Scaffold(
child: switch (status) { backgroundColor: Colors.transparent,
DictionaryStatus.finding || appBar: AppBar(
DictionaryStatus.found => const AppLoadingIndicator(), backgroundColor: Colors.transparent,
DictionaryStatus.none => Padding( systemOverlayStyle: TopGradient.overlayStyle(context),
padding: const EdgeInsets.all(24), title: Text(AppLocalizations.of(context).dictionaryTitle),
child: Text( ),
AppLocalizations.of(context).dictionaryNone, body: Center(
textAlign: TextAlign.center, child: switch (status) {
DictionaryStatus.finding ||
DictionaryStatus.found => const AppLoadingIndicator(),
DictionaryStatus.none => Padding(
padding: const EdgeInsets.all(24),
child: Text(
AppLocalizations.of(context).dictionaryNone,
textAlign: TextAlign.center,
),
), ),
), DictionaryStatus.unreachable => _Unreachable(onRetry: retry),
DictionaryStatus.unreachable => _Unreachable(onRetry: retry), },
}, ),
), ),
), ),
builder: (context, words) => StudysetDictionary( builder: (context, words) => StudysetDictionary(
@ -48,46 +57,68 @@ class const DictionaryScreen({required final String studysetId, super.key})
builder: (context, characters) { builder: (context, characters) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return switch ((words, characters)) { return switch ((words, characters)) {
(null, _) => Scaffold( (null, _) => TopGradient(
appBar: AppBar(title: Text(l10n.dictionaryTitle)), background: Theme.of(context).colorScheme.surface,
body: const Center(child: AppLoadingIndicator()), child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.dictionaryTitle),
),
body: const Center(child: AppLoadingIndicator()),
),
), ),
(final words?, null) => Scaffold( (final words?, null) => TopGradient(
appBar: AppBar(title: Text(l10n.dictionaryTitle)), background: Theme.of(context).colorScheme.surface,
body: _Dictionary( child: Scaffold(
dictionary: words, backgroundColor: Colors.transparent,
studysetId: studysetId, appBar: AppBar(
child: _DictionaryView(dictionary: words), backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.dictionaryTitle),
),
body: _Dictionary(
dictionary: words,
studysetId: studysetId,
child: _DictionaryView(dictionary: words),
),
), ),
), ),
(final words?, final characters?) => DefaultTabController( (final words?, final characters?) => DefaultTabController(
length: 3, length: 3,
child: Scaffold( child: TopGradient(
appBar: AppBar( background: Theme.of(context).colorScheme.surface,
title: Text(l10n.dictionaryTitle), child: Scaffold(
bottom: TabBar( backgroundColor: Colors.transparent,
tabs: [ appBar: AppBar(
Tab(text: l10n.dictionaryTabWords), backgroundColor: Colors.transparent,
Tab(text: l10n.dictionaryTabCharacters), systemOverlayStyle: TopGradient.overlayStyle(context),
Tab(text: l10n.dictionaryTabRadicals), title: Text(l10n.dictionaryTitle),
], bottom: TabBar(
tabs: [
Tab(text: l10n.dictionaryTabWords),
Tab(text: l10n.dictionaryTabCharacters),
Tab(text: l10n.dictionaryTabRadicals),
],
),
), ),
), // The characters and the radicals share one bloc: a kanji
// The characters and the radicals share one bloc: a kanji // added from its radical shows as added in a search too.
// added from its radical shows as added in a search too. body: _Dictionary(
body: _Dictionary( dictionary: characters,
dictionary: characters, studysetId: studysetId,
studysetId: studysetId, child: TabBarView(
child: TabBarView( children: [
children: [ _Dictionary(
_Dictionary( dictionary: words,
dictionary: words, studysetId: studysetId,
studysetId: studysetId, child: _DictionaryView(dictionary: words),
child: _DictionaryView(dictionary: words), ),
), _DictionaryView(dictionary: characters),
_DictionaryView(dictionary: characters), _Radicals(dictionary: characters),
_Radicals(dictionary: characters), ],
], ),
), ),
), ),
), ),

View file

@ -8,6 +8,7 @@ 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/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/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/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/learning_path/bloc/path_editor_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
@ -62,21 +63,28 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
_sync(_secondary, state.content.secondaryTitle ?? ''); _sync(_secondary, state.content.secondaryTitle ?? '');
} }
}, },
builder: (context, state) => Scaffold( builder: (context, state) => TopGradient(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, background: Theme.of(context).colorScheme.surfaceContainerLow,
appBar: AppBar(title: Text(l10n.pathEditTitle)), child: Scaffold(
body: switch (state) { backgroundColor: Colors.transparent,
PathEditorState(loaded: false) => const Center( appBar: AppBar(
child: AppLoadingIndicator(), backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.pathEditTitle),
), ),
PathEditorState(gone: true) => Center( body: switch (state) {
child: Padding( PathEditorState(loaded: false) => const Center(
padding: const EdgeInsets.all(24), child: AppLoadingIndicator(),
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
), ),
), PathEditorState(gone: true) => Center(
_ => _form(context, state), child: Padding(
}, padding: const EdgeInsets.all(24),
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
),
),
_ => _form(context, state),
},
),
), ),
); );
} }

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart'; import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.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/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/learning_path/bloc/path_runner_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
@ -33,42 +34,51 @@ class const PathRunnerScreen({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return BlocBuilder<PathRunnerBloc, PathRunnerState>( return BlocBuilder<PathRunnerBloc, PathRunnerState>(
builder: (context, state) => Scaffold( builder: (context, state) => TopGradient(
appBar: AppBar( background: Theme.of(context).colorScheme.surface,
leading: IconButton( child: Scaffold(
icon: const Icon(LucideIcons.x), backgroundColor: Colors.transparent,
tooltip: l10n.runnerClose, appBar: AppBar(
onPressed: () => Navigator.of(context).maybePop(), backgroundColor: Colors.transparent,
), systemOverlayStyle: TopGradient.overlayStyle(context),
title: state.steps.isEmpty || state.finished leading: IconButton(
? null icon: const Icon(LucideIcons.x),
: _StepsBar(state: state), tooltip: l10n.runnerClose,
actions: [ onPressed: () => Navigator.of(context).maybePop(),
if (state.step case final step? when !state.finished) ),
IconButton( title: state.steps.isEmpty || state.finished
icon: const Icon(LucideIcons.pencil), ? null
tooltip: l10n.runnerEditStep, : _StepsBar(state: state),
onPressed: () => context.push( actions: [
RoutePaths.pathStep(studysetId, pathId, step.id), if (state.step case final step? when !state.finished)
IconButton(
icon: const Icon(LucideIcons.pencil),
tooltip: l10n.runnerEditStep,
onPressed: () => context.push(
RoutePaths.pathStep(studysetId, pathId, step.id),
),
), ),
), if (state.loaded)
if (state.loaded) _PathMenu(studysetId: studysetId, pathId: pathId), _PathMenu(studysetId: studysetId, pathId: pathId),
], ],
),
body: switch (state) {
PathRunnerState(loaded: false) => const Center(
child: AppLoadingIndicator(),
),
PathRunnerState(finished: true) => _Finished(
state: state,
studysetId: studysetId,
),
PathRunnerState(step: null) => Center(
child: Text(l10n.runnerEmpty),
),
PathRunnerState(:final step?) => _Step(step: step, state: state),
},
bottomNavigationBar: state.step == null || state.finished
? null
: _Footer(state: state),
), ),
body: switch (state) {
PathRunnerState(loaded: false) => const Center(
child: AppLoadingIndicator(),
),
PathRunnerState(finished: true) => _Finished(
state: state,
studysetId: studysetId,
),
PathRunnerState(step: null) => Center(child: Text(l10n.runnerEmpty)),
PathRunnerState(:final step?) => _Step(step: step, state: state),
},
bottomNavigationBar: state.step == null || state.finished
? null
: _Footer(state: state),
), ),
); );
} }

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.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/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/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/step_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
@ -52,44 +53,51 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return BlocBuilder<StepEditorBloc, StepEditorState>( return BlocBuilder<StepEditorBloc, StepEditorState>(
builder: (context, state) => Scaffold( builder: (context, state) => TopGradient(
appBar: AppBar( background: Theme.of(context).colorScheme.surface,
title: Text( child: Scaffold(
state.loaded backgroundColor: Colors.transparent,
? '${l10n.stepEditTitle} · ${stepKindLabel(l10n, state.kind)}' appBar: AppBar(
: l10n.stepEditTitle, backgroundColor: Colors.transparent,
), systemOverlayStyle: TopGradient.overlayStyle(context),
actions: [ title: Text(
_SaveStatus(status: state.status), state.loaded
if (fromRelease(state.content.published)) ? '${l10n.stepEditTitle} · ${stepKindLabel(l10n, state.kind)}'
IconButton( : l10n.stepEditTitle,
tooltip: l10n.suggestToAuthor, ),
icon: const Icon(LucideIcons.pencilLine), actions: [
onPressed: () => suggestToAuthor( _SaveStatus(status: state.status),
context, if (fromRelease(state.content.published))
studysetId: IconButton(
GoRouterState.of(context).pathParameters['studysetId'] ?? tooltip: l10n.suggestToAuthor,
'', icon: const Icon(LucideIcons.pencilLine),
send: (feedback, body) => feedback.suggestStep( onPressed: () => suggestToAuthor(
state.content.normalized, context,
body: body, studysetId:
GoRouterState.of(context)
.pathParameters['studysetId'] ??
'',
send: (feedback, body) => feedback.suggestStep(
state.content.normalized,
body: body,
),
), ),
), ),
), ],
],
),
body: switch (state) {
StepEditorState(loaded: false) => const Center(
child: AppLoadingIndicator(),
), ),
StepEditorState(gone: true) => Center( body: switch (state) {
child: Padding( StepEditorState(loaded: false) => const Center(
padding: const EdgeInsets.all(24), child: AppLoadingIndicator(),
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
), ),
), StepEditorState(gone: true) => Center(
_ => _Form(state: state), child: Padding(
}, padding: const EdgeInsets.all(24),
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
),
),
_ => _Form(state: state),
},
),
), ),
); );
} }

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.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/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/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/feedback/widget/feedback_actions.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
@ -62,40 +63,46 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
_title.text = state.content.title; _title.text = state.content.title;
} }
}, },
builder: (context, state) => Scaffold( builder: (context, state) => TopGradient(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, background: Theme.of(context).colorScheme.surfaceContainerLow,
appBar: AppBar( child: Scaffold(
actions: [ backgroundColor: Colors.transparent,
_SaveStatus(status: state.status), appBar: AppBar(
if (fromRelease(state.content.published)) backgroundColor: Colors.transparent,
IconButton( systemOverlayStyle: TopGradient.overlayStyle(context),
tooltip: AppLocalizations.of(context).suggestToAuthor, actions: [
icon: const Icon(LucideIcons.pencilLine), _SaveStatus(status: state.status),
onPressed: () => suggestToAuthor( if (fromRelease(state.content.published))
context, IconButton(
studysetId: tooltip: AppLocalizations.of(context).suggestToAuthor,
GoRouterState.of(context).pathParameters['studysetId'] ?? icon: const Icon(LucideIcons.pencilLine),
'', onPressed: () => suggestToAuthor(
send: (feedback, body) => feedback.suggestNote( context,
state.content.normalized, studysetId:
body: body, GoRouterState.of(context)
.pathParameters['studysetId'] ??
'',
send: (feedback, body) => feedback.suggestNote(
state.content.normalized,
body: body,
),
), ),
), ),
), ],
],
),
body: switch (state) {
NoteEditorState(loaded: false) => const Center(
child: AppLoadingIndicator(),
), ),
NoteEditorState(gone: true) => Center( body: switch (state) {
child: Padding( NoteEditorState(loaded: false) => const Center(
padding: const EdgeInsets.all(24), child: AppLoadingIndicator(),
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
), ),
), NoteEditorState(gone: true) => Center(
_ => _Note(state: state, title: _title), child: Padding(
}, padding: const EdgeInsets.all(24),
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
),
),
_ => _Note(state: state, title: _title),
},
),
), ),
); );
} }

View file

@ -1,6 +1,7 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter/services.dart';
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';
@ -8,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.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/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/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/assistant/model/assistant_topic.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
@ -44,66 +46,68 @@ class const StudyScreen({
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Widget build(
BlocListener<StudySessionBloc, StudySessionState>( BuildContext context,
listenWhen: (previous, current) => ) => BlocListener<StudySessionBloc, StudySessionState>(
current is StudySessionStateReviewsDue, listenWhen: (previous, current) => current is StudySessionStateReviewsDue,
listener: (context, state) => _askReviewFirst( listener: (context, state) => _askReviewFirst(
context, context,
state as StudySessionStateReviewsDue, state as StudySessionStateReviewsDue,
), ),
child: BlocBuilder<StudySessionBloc, StudySessionState>( child: BlocBuilder<StudySessionBloc, StudySessionState>(
builder: (context, state) { builder: (context, state) {
final header = _Header( final header = _Header(
studysetId: studysetId, studysetId: studysetId,
title: title,
state: state,
);
return PopScope(
canPop: state is! StudySessionStateShowing,
onPopInvokedWithResult: (didPop, _) async {
if (!didPop && await _confirmLeave(context) && context.mounted) {
Navigator.of(context).pop();
}
},
child: switch (state) {
final StudySessionStateShowing showing => _Showing(
showing,
header: header,
title: title, title: title,
state: state, ),
); _ => TopGradient(
return PopScope( background: Theme.of(context).colorScheme.surface,
canPop: state is! StudySessionStateShowing, child: Scaffold(
onPopInvokedWithResult: (didPop, _) async { backgroundColor: Colors.transparent,
if (!didPop && body: SafeArea(
await _confirmLeave(context) && child: Column(
context.mounted) { crossAxisAlignment: CrossAxisAlignment.stretch,
Navigator.of(context).pop(); children: [
} header,
}, Expanded(
child: switch (state) { child: switch (state) {
final StudySessionStateShowing showing => _Showing( StudySessionStateNothing(:final next) => _Nothing(
showing, next: next,
header: header, ),
title: title, final StudySessionStateRoundDone done => _RoundDone(
), done,
_ => Scaffold( ),
body: SafeArea( StudySessionStateFailed() => const _Failed(),
child: Column( // Behind the question about them.
crossAxisAlignment: CrossAxisAlignment.stretch, StudySessionStateReviewsDue() =>
children: [ const SizedBox.shrink(),
header, _ => const Center(child: AppLoadingIndicator()),
Expanded( },
child: switch (state) { ),
StudySessionStateNothing(:final next) => _Nothing( ],
next: next,
),
final StudySessionStateRoundDone done => _RoundDone(
done,
),
StudySessionStateFailed() => const _Failed(),
// Behind the question about them.
StudySessionStateReviewsDue() =>
const SizedBox.shrink(),
_ => const Center(child: AppLoadingIndicator()),
},
),
],
),
), ),
), ),
}, ),
); ),
}, },
), );
); },
),
);
} }
/// Asks, with reviews due beyond the tags chosen, whether to study those /// Asks, with reviews due beyond the tags chosen, whether to study those
@ -578,124 +582,148 @@ class const _Showing(
CardType.frontBack || CardType.vocabulary || CardType.character => true, CardType.frontBack || CardType.vocabulary || CardType.character => true,
CardType.multipleChoice || CardType.matching => false, CardType.multipleChoice || CardType.matching => false,
}; };
return Scaffold( return AnnotatedRegion<SystemUiOverlayStyle>(
backgroundColor: theme.colorScheme.surfaceContainerLow, value: TopGradient.overlayStyle(context),
body: Stack( child: TopGradient(
children: [ background: theme.colorScheme.surfaceContainerLow,
SafeArea( child: Scaffold(
child: Column( backgroundColor: Colors.transparent,
crossAxisAlignment: CrossAxisAlignment.stretch, body: Stack(
children: [ children: [
ColoredBox( SafeArea(
color: theme.colorScheme.surfaceContainer, child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ // On the studyset's colour, with an edge below, as Vaia's.
header, DecoratedBox(
Padding( decoration: BoxDecoration(
padding: const EdgeInsets.symmetric(horizontal: 16), border: Border(
child: state.cram bottom: BorderSide(
? CramProgress( color: theme.colorScheme.outlineVariant,
tally: state.cramTally,
unrated: state.cramUnrated,
)
: PillProgress(pills: state.pills),
),
const SizedBox(height: 12),
],
),
),
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1100),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: body,
),
), ),
), ),
), ),
// Asking Claude about the card, in the card's lower child: Column(
// right corner, above the ratings. Unfolded, the field crossAxisAlignment: CrossAxisAlignment.stretch,
// spans the card, the keyboard pushing it up. children: [
Positioned( header,
right: 28, Padding(
bottom: 24, padding: const EdgeInsets.symmetric(horizontal: 16),
child: AskClaudeButton( child: state.cram
topic: AssistantTopicCard( ? CramProgress(
studysetTitle: title, tally: state.cramTally,
card: card.content, unrated: state.cramUnrated,
)
: PillProgress(pills: state.pills),
), ),
small: true, const SizedBox(height: 12),
// Within the card's sides. ],
expandedWidth: math.min(
MediaQuery.sizeOf(context).width - 56,
_fieldMaxWidth,
),
),
), ),
],
),
),
if (ratedBelow)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Center(
child: state.cram
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.studyPrevious,
onPressed: state.canGoBack
? () => _send(
context,
const StudySessionEventMoved(-1),
)
: null,
icon: const Icon(LucideIcons.chevronLeft),
),
const SizedBox(width: 12),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 540,
),
child: ratings,
),
),
const SizedBox(width: 12),
IconButton.filledTonal(
tooltip: l10n.studySkipCard,
onPressed: () => _send(
context,
const StudySessionEventMoved(1),
),
icon: const Icon(LucideIcons.chevronRight),
),
],
)
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: ratings,
),
), ),
), Expanded(
], child: Stack(
), children: [
), Positioned.fill(
if (state.locked) child: Center(
Positioned.fill( child: ConstrainedBox(
child: StudyLock( constraints: const BoxConstraints(
onUnlock: () => maxWidth: 1100,
_send(context, const StudySessionEventLockToggled()), ),
child: Padding(
padding: const EdgeInsets.fromLTRB(
16,
12,
16,
0,
),
child: body,
),
),
),
),
// Asking Claude about the card, in the card's lower
// right corner, above the ratings. Unfolded, the field
// spans the card, the keyboard pushing it up.
Positioned(
right: 28,
bottom: 24,
child: AskClaudeButton(
topic: AssistantTopicCard(
studysetTitle: title,
card: card.content,
),
small: true,
// Within the card's sides.
expandedWidth: math.min(
MediaQuery.sizeOf(context).width - 56,
_fieldMaxWidth,
),
),
),
],
),
),
if (ratedBelow)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Center(
child: state.cram
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.studyPrevious,
onPressed: state.canGoBack
? () => _send(
context,
const StudySessionEventMoved(-1),
)
: null,
icon: const Icon(LucideIcons.chevronLeft),
),
const SizedBox(width: 12),
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 540,
),
child: ratings,
),
),
const SizedBox(width: 12),
IconButton.filledTonal(
tooltip: l10n.studySkipCard,
onPressed: () => _send(
context,
const StudySessionEventMoved(1),
),
icon: const Icon(
LucideIcons.chevronRight,
),
),
],
)
: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 540,
),
child: ratings,
),
),
),
],
),
), ),
), if (state.locked)
], Positioned.fill(
child: StudyLock(
onUnlock: () =>
_send(context, const StudySessionEventLockToggled()),
),
),
],
),
),
), ),
); );
} }

View file

@ -293,7 +293,7 @@ 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,
extent: 280, extent: 180,
// The space the screen gets, which beside the rail is less than the // The space the screen gets, which beside the rail is less than the
// window's. // window's.
child: LayoutBuilder( child: LayoutBuilder(

View file

@ -0,0 +1,105 @@
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/router/route_paths.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/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cards;
setUp(() async {
cards = FakeCardGateway();
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'languages',
encodeStudysetPayload(
StudysetContent(
title: 'Languages',
description: RichContent.empty(),
colorName: 'fern',
),
),
)
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()),
),
parentId: 'languages',
);
final repository = CardRepository(cards);
addTearDown(repository.dispose);
await repository.create(
'japanese',
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
});
Future<void> show(WidgetTester tester, String location, double width) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(studysetGateway: studysets, cardGateway: cards),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// The colour the screen's wash takes.
Color wash(WidgetTester tester) {
final gradient = find.byType(TopGradient).last;
return tester.widget<TopGradient>(gradient).color ??
ScreenColor.of(tester.element(gradient));
}
final fern = PaletteColor.fern.background;
for (final (name, location) in [
('studying it', RoutePaths.study('japanese')),
('its cards', RoutePaths.cardBrowser('japanese')),
('writing a card', RoutePaths.newCard('japanese')),
('a note', RoutePaths.newNote('japanese')),
('its dictionary', RoutePaths.dictionary('japanese')),
]) {
testWidgets("$name washes in the studyset's colour, inherited from "
'above it', (tester) async {
await show(tester, location, 400);
expect(wash(tester), fern);
});
}
for (final (name, location) in [
('studying it', RoutePaths.study('japanese')),
('its cards', RoutePaths.cardBrowser('japanese')),
]) {
testWidgets('$name, over the tabs on a wide window, sits framed in its '
'colour, without the rail', (tester) async {
await show(tester, location, 1200);
expect(find.byType(NavigationRail), findsNothing);
expect(
tester
.widget<ScreenTintScope>(find.byType(ScreenTintScope).last)
.notifier!
.color,
fern,
);
});
}
}

View file

@ -47,13 +47,17 @@ void main() {
} }
}); });
test('is a deep tone of cobalt in dark mode, a tint in light mode', () { test('is a deep shade 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;
expect( expect(
TopGradient.topTone(dark, Brand.primary, dark.surface), TopGradient.topTone(dark, Brand.primary, dark.surface),
Color.lerp(dark.surface, Brand.primary, TopGradient.darkMix), Color.lerp(
const Color(0xFF000000),
Brand.primary,
TopGradient.darkShade,
),
); );
expect( expect(
TopGradient.topTone(light, Brand.primary, light.surface), TopGradient.topTone(light, Brand.primary, light.surface),
@ -61,14 +65,38 @@ void main() {
); );
}); });
test('half as strong inside the panel', () {
final dark = appTheme(Brightness.dark).colorScheme;
final full = Color.lerp(
const Color(0xFF000000),
Brand.primary,
TopGradient.darkShade,
)!;
expect(
TopGradient.topTone(
dark,
Brand.primary,
dark.surface,
share: TopGradient.panelShare,
),
Color.lerp(dark.surface, full, TopGradient.panelShare),
);
});
test('mixes in less where the full share would be unreadable', () { test('mixes in less where the full share would be unreadable', () {
final dark = appTheme(Brightness.dark).colorScheme; final dark = appTheme(Brightness.dark).colorScheme;
// White text can't sit on a light yellow at 60 %. // Light text can't sit on a shade of white at 42 %.
const yellow = Color(0xFFFFEE00); const white = Color(0xFFFFFFFF);
final top = TopGradient.topTone(dark, yellow, dark.surface); final top = TopGradient.topTone(dark, white, dark.surface);
expect(top, isNot(Color.lerp(dark.surface, yellow, TopGradient.darkMix))); expect(
top,
isNot(
Color.lerp(const Color(0xFF000000), white, TopGradient.darkShade),
),
);
expect( expect(
TopGradient.contrast(top, dark.onSurface), TopGradient.contrast(top, dark.onSurface),
greaterThanOrEqualTo(TopGradient.minContrast), greaterThanOrEqualTo(TopGradient.minContrast),

View file

@ -175,7 +175,7 @@ void main() {
}); });
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>(
find.descendant( find.descendant(
of: find.byType(StudysetScreen), of: find.byType(StudysetScreen),