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:
parent
d76a49b1c4
commit
5f8fade41c
18 changed files with 972 additions and 615 deletions
|
|
@ -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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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/route_paths.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/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/auth/widget/profile_screen.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/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_tree.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_screen.dart';
|
||||
|
|
@ -168,7 +173,8 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
return _inStudyset(
|
||||
context,
|
||||
studysetId,
|
||||
(studyset, _) => BlocProvider(
|
||||
(studyset, _) => FramedScreen(
|
||||
child: BlocProvider(
|
||||
create: (context) => StudySessionBloc(
|
||||
person.studysets,
|
||||
person.cards,
|
||||
|
|
@ -184,6 +190,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
title: studyset.content.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -194,13 +201,15 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
builder: (context, state) => _inStudyset(
|
||||
context,
|
||||
state.pathParameters['studysetId']!,
|
||||
(studyset, all) => CardBrowserScreen(
|
||||
(studyset, all) => FramedScreen(
|
||||
child: CardBrowserScreen(
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
subtree: state.uri.queryParameters['subtree'] == '1',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The composer: writing a studyset's cards, opened on a new card or on
|
||||
// one of them. Before the card route: its `new` would otherwise read as
|
||||
// a card's id.
|
||||
|
|
@ -231,11 +240,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
|||
),
|
||||
GoRoute(
|
||||
path: RoutePaths.settings,
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
builder: (context, state) => const FramedScreen(child: SettingsScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'assistant',
|
||||
builder: (context, state) => const ConnectClaudeScreen(),
|
||||
builder: (context, state) =>
|
||||
const FramedScreen(child: ConnectClaudeScreen()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -325,8 +335,15 @@ Widget _inStudyset(
|
|||
builder: (studyset, all) => StudysetLanguageScope(
|
||||
studyset: studyset,
|
||||
all: 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// The note [noteId] of the studyset [studysetId], or a new one without; a
|
||||
|
|
@ -353,7 +370,8 @@ Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
|
|||
return _inStudyset(
|
||||
context,
|
||||
studysetId,
|
||||
(studyset, _) => BlocProvider(
|
||||
(studyset, _) => FramedScreen(
|
||||
child: BlocProvider(
|
||||
create: (context) =>
|
||||
CardComposerBloc(person.cards, person.tags, studysetId)
|
||||
..add(const CardComposerEventStarted()),
|
||||
|
|
@ -362,5 +380,6 @@ Widget _composer(BuildContext context, String studysetId, {String? cardId}) {
|
|||
initialCardId: cardId,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/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:halkyon_learn_flutter/src/core/widget/tinted_frame.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -58,27 +58,11 @@ class _AppShellState() extends State<AppShell> {
|
|||
)
|
||||
: Icon(data);
|
||||
if (context.windowSize.isAtLeastMedium) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ScreenTintScope(
|
||||
notifier: _tint,
|
||||
child: 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: [
|
||||
NavigationRail(
|
||||
child: TintFrame(
|
||||
tint: _tint,
|
||||
rail: NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
|
|
@ -88,18 +72,12 @@ class _AppShellState() extends State<AppShell> {
|
|||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: icon(destination, destination.icon),
|
||||
selectedIcon: icon(
|
||||
destination,
|
||||
destination.selectedIcon,
|
||||
),
|
||||
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
|
||||
/// in a rounded tile that reads on light and dark rails alike.
|
||||
class const _RailLogo() extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -32,3 +32,22 @@ class const ScreenTintScope({
|
|||
static ScreenTint? maybeOf(BuildContext context) =>
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
121
halkyon_learn_flutter/lib/src/core/widget/tinted_frame.dart
Normal file
121
halkyon_learn_flutter/lib/src/core/widget/tinted_frame.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,33 @@
|
|||
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/theme/brand.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/screen_tint.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
/// cobalt on the tabs' screens and Settings, a studyset's colour on its
|
||||
/// screen and every screen of it ([ScreenColor]), as Vaia does (decided
|
||||
/// 2026-10-10). While the screen is the one in view (its tab showing, its
|
||||
/// 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
|
||||
/// mode (a deep tone, cobalt a navy) and 35 % in light mode (a pastel tint),
|
||||
/// less where the theme's text on it would fall below WCAG AA (4.5:1), so
|
||||
/// the app bar reads on every palette colour. It fades out over [extent]
|
||||
/// below the status bar and stays put while the screen's content scrolls.
|
||||
/// The wash starts in dark mode as a deep shade of [color] (42 % of it over
|
||||
/// black, as Vaia's: cerise a maroon, cobalt a navy), in light mode as a
|
||||
/// pastel tint (35 % of it over the background); less where the theme's
|
||||
/// text on it would fall below WCAG AA (4.5:1), so the app bar reads on
|
||||
/// 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
|
||||
/// 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
|
||||
/// (the Scaffold's default) never scrolls under its title.
|
||||
class const TopGradient({
|
||||
/// The colour washed in.
|
||||
required final Color color,
|
||||
/// The colour washed in; the [ScreenColor] above without.
|
||||
final Color? color,
|
||||
|
||||
/// What the wash fades into: the screen's background.
|
||||
required final Color background,
|
||||
|
|
@ -29,13 +35,13 @@ class const TopGradient({
|
|||
/// The screen, transparent where the wash should show.
|
||||
required final Widget child,
|
||||
|
||||
/// How far below the status bar the wash reaches: far on Home and a
|
||||
/// studyset, as far as the header elsewhere.
|
||||
final double extent = 200,
|
||||
/// How far below the status bar the wash reaches: far on Home, about as
|
||||
/// far as the header elsewhere.
|
||||
final double extent = 160,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// The share of [color] at the top in dark mode.
|
||||
static const darkMix = 0.6;
|
||||
/// The share of [color] over black at the top in dark mode.
|
||||
static const darkShade = 0.42;
|
||||
|
||||
/// The share of [color] at the top in light mode.
|
||||
static const lightMix = 0.35;
|
||||
|
|
@ -43,8 +49,12 @@ class const TopGradient({
|
|||
/// The contrast text keeps on a tone (WCAG AA).
|
||||
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;
|
||||
/// How far down the frame fades into the background, as a share of its
|
||||
/// 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
|
||||
State<TopGradient> createState() => _TopGradientState();
|
||||
|
|
@ -61,15 +71,14 @@ class const TopGradient({
|
|||
double share = 1,
|
||||
}) {
|
||||
final on = text ?? scheme.onSurface;
|
||||
final strongest =
|
||||
share *
|
||||
switch (scheme.brightness) {
|
||||
Brightness.dark => darkMix,
|
||||
Brightness.light => lightMix,
|
||||
};
|
||||
final dark = scheme.brightness == Brightness.dark;
|
||||
// In dark mode a shade of the colour, faded into the background by
|
||||
// [share]; in light mode a tint of it over the background.
|
||||
final full = dark
|
||||
? Color.lerp(const Color(0xFF000000), color, darkShade)!
|
||||
: Color.lerp(background, color, lightMix)!;
|
||||
for (var step = 0; step <= 20; step++) {
|
||||
final mix = strongest * (1 - step / 20);
|
||||
final tone = Color.lerp(background, color, mix)!;
|
||||
final tone = Color.lerp(background, full, share * (1 - step / 20))!;
|
||||
if (contrast(tone, on) >= minContrast) {
|
||||
return tone;
|
||||
}
|
||||
|
|
@ -77,23 +86,18 @@ class const TopGradient({
|
|||
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.
|
||||
/// The frame around a panel from medium windows on (the navigation rail,
|
||||
/// the margin around the panel): [color] from the top, as strong as the
|
||||
/// rail's labels (`onSurfaceVariant`) allow, faded into the background
|
||||
/// [frameFade] of the way down.
|
||||
static LinearGradient frame(ColorScheme scheme, Color color) =>
|
||||
LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, frameFade],
|
||||
colors: [
|
||||
topTone(scheme, color, scheme.surface, text: scheme.onSurfaceVariant),
|
||||
topTone(
|
||||
scheme,
|
||||
color,
|
||||
scheme.surface,
|
||||
text: scheme.onSurfaceVariant,
|
||||
share: frameFootShare,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -126,12 +130,16 @@ class _TopGradientState() extends State<TopGradient> {
|
|||
/// top.
|
||||
var _inView = false;
|
||||
|
||||
/// The colour washed in: the widget's, else the screen's.
|
||||
var _color = Brand.primary;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_inView =
|
||||
TabVisibility.of(context) &&
|
||||
(ModalRoute.of(context)?.isCurrent ?? true);
|
||||
_color = widget.color ?? ScreenColor.of(context);
|
||||
_claim();
|
||||
}
|
||||
|
||||
|
|
@ -139,6 +147,7 @@ class _TopGradientState() extends State<TopGradient> {
|
|||
void didUpdateWidget(TopGradient oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.color != widget.color) {
|
||||
_color = widget.color ?? ScreenColor.of(context);
|
||||
_claim();
|
||||
}
|
||||
}
|
||||
|
|
@ -150,7 +159,7 @@ class _TopGradientState() extends State<TopGradient> {
|
|||
if (tint == null || !_inView) {
|
||||
return;
|
||||
}
|
||||
final color = widget.color;
|
||||
final color = _color;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _inView) {
|
||||
tint.show(color);
|
||||
|
|
@ -161,7 +170,16 @@ class _TopGradientState() extends State<TopGradient> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
color: widget.background,
|
||||
child: Stack(
|
||||
|
|
|
|||
|
|
@ -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/widget/app_loading_indicator.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/feature/card/bloc/card_browser_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
|
|
@ -385,8 +386,13 @@ class _BrowserState() extends State<_Browser> {
|
|||
],
|
||||
),
|
||||
};
|
||||
return Scaffold(
|
||||
return TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(
|
||||
widget.subtree ? l10n.cardBrowserTitleSubtree(base) : base,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -454,6 +460,7 @@ class _BrowserState() extends State<_Browser> {
|
|||
bottomNavigationBar: state.selecting && !expanded
|
||||
? _SelectionBar(actions: _actions(state))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/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/top_gradient.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_stack_tile.dart';
|
||||
|
|
@ -201,8 +202,13 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
unawaited(_close());
|
||||
}
|
||||
},
|
||||
child: TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
leading: BackButton(onPressed: _close),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -226,7 +232,10 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
CardComposerSaving.saved => l10n.composerAllSaved,
|
||||
}
|
||||
case final status?)
|
||||
Text(status, style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(
|
||||
status,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -241,7 +250,8 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
expanded: expanded,
|
||||
onAdd: _addDraft,
|
||||
onSelect: _select,
|
||||
onToggle: () => setState(() => _stackExpanded = !expanded),
|
||||
onToggle: () =>
|
||||
setState(() => _stackExpanded = !expanded),
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1, thickness: 1),
|
||||
|
|
@ -284,6 +294,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/layout/readable_width.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/data/dictionary_adder.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
|
|
@ -26,8 +27,15 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => StudysetDictionary(
|
||||
missing: (context, status, retry) => Scaffold(
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context).dictionaryTitle)),
|
||||
missing: (context, status, retry) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(AppLocalizations.of(context).dictionaryTitle),
|
||||
),
|
||||
body: Center(
|
||||
child: switch (status) {
|
||||
DictionaryStatus.finding ||
|
||||
|
|
@ -43,27 +51,49 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, words) => StudysetDictionary(
|
||||
kind: DictionaryKind.characters,
|
||||
builder: (context, characters) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return switch ((words, characters)) {
|
||||
(null, _) => Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.dictionaryTitle)),
|
||||
(null, _) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
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(
|
||||
appBar: AppBar(title: Text(l10n.dictionaryTitle)),
|
||||
),
|
||||
(final words?, null) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
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(
|
||||
length: 3,
|
||||
child: TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(l10n.dictionaryTitle),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
|
|
@ -92,6 +122,7 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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/widget/app_loading_indicator.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/feature/learning_path/bloc/path_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
|
|
@ -62,9 +63,15 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
_sync(_secondary, state.content.secondaryTitle ?? '');
|
||||
}
|
||||
},
|
||||
builder: (context, state) => Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
appBar: AppBar(title: Text(l10n.pathEditTitle)),
|
||||
builder: (context, state) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(l10n.pathEditTitle),
|
||||
),
|
||||
body: switch (state) {
|
||||
PathEditorState(loaded: false) => const Center(
|
||||
child: AppLoadingIndicator(),
|
||||
|
|
@ -78,6 +85,7 @@ class _PathEditorScreenState() extends State<PathEditorScreen> {
|
|||
_ => _form(context, state),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/widget/app_loading_indicator.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/feature/learning_path/bloc/path_runner_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
|
|
@ -33,8 +34,13 @@ class const PathRunnerScreen({
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocBuilder<PathRunnerBloc, PathRunnerState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
builder: (context, state) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
tooltip: l10n.runnerClose,
|
||||
|
|
@ -52,7 +58,8 @@ class const PathRunnerScreen({
|
|||
RoutePaths.pathStep(studysetId, pathId, step.id),
|
||||
),
|
||||
),
|
||||
if (state.loaded) _PathMenu(studysetId: studysetId, pathId: pathId),
|
||||
if (state.loaded)
|
||||
_PathMenu(studysetId: studysetId, pathId: pathId),
|
||||
],
|
||||
),
|
||||
body: switch (state) {
|
||||
|
|
@ -63,13 +70,16 @@ class const PathRunnerScreen({
|
|||
state: state,
|
||||
studysetId: studysetId,
|
||||
),
|
||||
PathRunnerState(step: null) => Center(child: Text(l10n.runnerEmpty)),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/layout/readable_width.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/learning_path/bloc/step_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
|
|
@ -52,8 +53,13 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocBuilder<StepEditorBloc, StepEditorState>(
|
||||
builder: (context, state) => Scaffold(
|
||||
builder: (context, state) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(
|
||||
state.loaded
|
||||
? '${l10n.stepEditTitle} · ${stepKindLabel(l10n, state.kind)}'
|
||||
|
|
@ -68,7 +74,8 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
|
|||
onPressed: () => suggestToAuthor(
|
||||
context,
|
||||
studysetId:
|
||||
GoRouterState.of(context).pathParameters['studysetId'] ??
|
||||
GoRouterState.of(context)
|
||||
.pathParameters['studysetId'] ??
|
||||
'',
|
||||
send: (feedback, body) => feedback.suggestStep(
|
||||
state.content.normalized,
|
||||
|
|
@ -91,6 +98,7 @@ class _StepEditorScreenState() extends State<StepEditorScreen> {
|
|||
_ => _Form(state: state),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/widget/app_loading_indicator.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/note/bloc/note_editor_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
|
|
@ -62,9 +63,13 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
|
|||
_title.text = state.content.title;
|
||||
}
|
||||
},
|
||||
builder: (context, state) => Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
builder: (context, state) => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
actions: [
|
||||
_SaveStatus(status: state.status),
|
||||
if (fromRelease(state.content.published))
|
||||
|
|
@ -74,7 +79,8 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
|
|||
onPressed: () => suggestToAuthor(
|
||||
context,
|
||||
studysetId:
|
||||
GoRouterState.of(context).pathParameters['studysetId'] ??
|
||||
GoRouterState.of(context)
|
||||
.pathParameters['studysetId'] ??
|
||||
'',
|
||||
send: (feedback, body) => feedback.suggestNote(
|
||||
state.content.normalized,
|
||||
|
|
@ -97,6 +103,7 @@ class _NoteEditorScreenState() extends State<NoteEditorScreen> {
|
|||
_ => _Note(state: state, title: _title),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.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/widget/app_loading_indicator.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/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/ask_claude.dart';
|
||||
|
|
@ -44,10 +46,10 @@ class const StudyScreen({
|
|||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocListener<StudySessionBloc, StudySessionState>(
|
||||
listenWhen: (previous, current) =>
|
||||
current is StudySessionStateReviewsDue,
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
) => BlocListener<StudySessionBloc, StudySessionState>(
|
||||
listenWhen: (previous, current) => current is StudySessionStateReviewsDue,
|
||||
listener: (context, state) => _askReviewFirst(
|
||||
context,
|
||||
state as StudySessionStateReviewsDue,
|
||||
|
|
@ -62,9 +64,7 @@ class const StudyScreen({
|
|||
return PopScope(
|
||||
canPop: state is! StudySessionStateShowing,
|
||||
onPopInvokedWithResult: (didPop, _) async {
|
||||
if (!didPop &&
|
||||
await _confirmLeave(context) &&
|
||||
context.mounted) {
|
||||
if (!didPop && await _confirmLeave(context) && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
|
|
@ -74,7 +74,10 @@ class const StudyScreen({
|
|||
header: header,
|
||||
title: title,
|
||||
),
|
||||
_ => Scaffold(
|
||||
_ => TopGradient(
|
||||
background: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -99,6 +102,7 @@ class const StudyScreen({
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -578,16 +582,27 @@ class const _Showing(
|
|||
CardType.frontBack || CardType.vocabulary || CardType.character => true,
|
||||
CardType.multipleChoice || CardType.matching => false,
|
||||
};
|
||||
return Scaffold(
|
||||
backgroundColor: theme.colorScheme.surfaceContainerLow,
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: TopGradient.overlayStyle(context),
|
||||
child: TopGradient(
|
||||
background: theme.colorScheme.surfaceContainerLow,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
// On the studyset's colour, with an edge below, as Vaia's.
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
@ -611,9 +626,16 @@ class const _Showing(
|
|||
Positioned.fill(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1100),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 1100,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
12,
|
||||
16,
|
||||
0,
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
|
|
@ -675,12 +697,16 @@ class const _Showing(
|
|||
context,
|
||||
const StudySessionEventMoved(1),
|
||||
),
|
||||
icon: const Icon(LucideIcons.chevronRight),
|
||||
icon: const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 540),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 540,
|
||||
),
|
||||
child: ratings,
|
||||
),
|
||||
),
|
||||
|
|
@ -697,6 +723,8 @@ class const _Showing(
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
builder: (context, fab) => TopGradient(
|
||||
color: color,
|
||||
background: theme.colorScheme.surfaceContainerLow,
|
||||
extent: 280,
|
||||
extent: 180,
|
||||
// The space the screen gets, which beside the rail is less than the
|
||||
// window's.
|
||||
child: LayoutBuilder(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 light = appTheme(Brightness.light).colorScheme;
|
||||
|
||||
expect(
|
||||
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(
|
||||
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', () {
|
||||
final dark = appTheme(Brightness.dark).colorScheme;
|
||||
// White text can't sit on a light yellow at 60 %.
|
||||
const yellow = Color(0xFFFFEE00);
|
||||
// Light text can't sit on a shade of white at 42 %.
|
||||
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(
|
||||
TopGradient.contrast(top, dark.onSurface),
|
||||
greaterThanOrEqualTo(TopGradient.minContrast),
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ void main() {
|
|||
});
|
||||
|
||||
group('Its colour washes down from the top', () {
|
||||
Color wash(WidgetTester tester) => tester
|
||||
Color? wash(WidgetTester tester) => tester
|
||||
.widget<TopGradient>(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetScreen),
|
||||
|
|
|
|||
Loading…
Reference in a new issue