feat(theme): a colour washing down from the top of Home and a studyset

Home starts in cobalt and a studyset's screen in its colour, its own or the
nearest above it (cobalt where none has one), fading into the background
below the app bar, which stays clear above it. The top tone is 60 % of
the colour in dark mode (cobalt a navy, cerise a maroon) and 35 % in light
mode (a pastel tint), less where the theme's text would fall below WCAG AA
on it; a test holds every palette colour to that in both modes. The
status bar's icons follow the tone. Content stays below the app bar, so
nothing scrolls under its title.

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 11:38:58 +02:00
commit e5d5fddea5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 468 additions and 161 deletions

View file

@ -0,0 +1,110 @@
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
/// A colour washing down from the top of a screen into its [background]:
/// cobalt on Home, a studyset's own colour on its screen.
///
/// 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 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,
/// What the wash fades into: the screen's background.
required final Color background,
/// The screen, transparent where the wash should show.
required final Widget child,
/// How far below the status bar the wash reaches.
final double extent = 360,
super.key,
}) extends StatelessWidget {
/// The share of [color] at the top in dark mode.
static const darkMix = 0.6;
/// The share of [color] at the top in light mode.
static const lightMix = 0.35;
/// The contrast the theme's text keeps on the top tone (WCAG AA).
static const minContrast = 4.5;
@override
Widget build(BuildContext context) {
final top = topTone(Theme.of(context).colorScheme, color, background);
return ColoredBox(
color: background,
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
height: MediaQuery.paddingOf(context).top + extent,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [top, background],
),
),
),
),
child,
],
),
);
}
/// The tone at the very top for [color] over [background] in [scheme]: as
/// much of [color] as the scheme's brightness allows, less until
/// [ColorScheme.onSurface] keeps [minContrast] on it.
static Color topTone(ColorScheme scheme, Color color, Color background) {
final text = scheme.onSurface;
final strongest = switch (scheme.brightness) {
Brightness.dark => darkMix,
Brightness.light => lightMix,
};
for (var step = 0; step <= 20; step++) {
final mix = strongest * (1 - step / 20);
final tone = Color.lerp(background, color, mix)!;
if (contrast(tone, text) >= minContrast) {
return tone;
}
}
return background;
}
/// The contrast ratio of [a] and [b], as WCAG defines it: 1 to 21.
static double contrast(Color a, Color b) {
final la = a.computeLuminance();
final lb = b.computeLuminance();
final (lighter, darker) = la > lb ? (la, lb) : (lb, la);
return (lighter + 0.05) / (darker + 0.05);
}
/// The status bar's style above the wash: light icons on the dark
/// theme's deep tone, dark ones on the light theme's tint. Only the
/// status bar's fields, as an app bar's own style has; the navigation
/// bar stays as it is.
static SystemUiOverlayStyle overlayStyle(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
return SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: dark ? Brightness.light : Brightness.dark,
// iOS names the bar's background instead of its icons.
statusBarBrightness: dark ? Brightness.dark : Brightness.light,
systemStatusBarContrastEnforced: false,
);
}
}

View file

@ -7,7 +7,9 @@ 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/sync/widget/app_mismatch_banner.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_trouble_badge.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
@ -22,7 +24,8 @@ import 'package:material_ui/material_ui.dart';
/// The home tab, where the app opens: a study dashboard. The streak sits in
/// the app bar; below it the study goal, the week and a year of activity,
/// in the order the person arranged them.
/// in the order the person arranged them. Cobalt washes down from the top
/// ([TopGradient]).
///
/// Long-pressing a widget arranges the dashboard: a handle to drag each
/// widget elsewhere, an eye to hide or show it, Done to finish. The
@ -80,63 +83,79 @@ class _HomeState() extends State<_Home> {
setState(() => _arranging = false);
}
},
child: Scaffold(
appBar: _arranging
? AppBar(
leading: IconButton(
tooltip: l10n.homeDone,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _arranging = false),
),
title: Text(l10n.homeArrange),
actions: [
TextButton(
onPressed: () => setState(() => _arranging = false),
child: Text(l10n.homeDone),
),
const SizedBox(width: 8),
],
)
: AppBar(
title: Text(l10n.homeTitle),
actions: [
if (loaded != null && today != null)
StreakChip(
streak: loaded.streak,
week: weekOf(
today,
loaded.activity,
loaded.restDays,
loaded.now,
),
today: today,
),
IconButton(
icon: const SyncTroubleBadge(
child: Icon(LucideIcons.settings),
),
tooltip: l10n.settingsTitle,
onPressed: () => context.push(RoutePaths.settings),
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
const AppMismatchBanner(),
Expanded(
child: loaded == null || today == null
? const Center(child: AppLoadingIndicator())
: _dashboard(loaded, today),
),
],
),
child: TopGradient(
color: Brand.primary,
background: Theme.of(context).colorScheme.surface,
child: _scaffold(context, l10n, loaded, today),
),
);
},
);
}
Widget _scaffold(
BuildContext context,
AppLocalizations l10n,
HomeStateLoaded? loaded,
DateTime? today,
) => Scaffold(
backgroundColor: Colors.transparent,
appBar: _arranging
? AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
leading: IconButton(
tooltip: l10n.homeDone,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _arranging = false),
),
title: Text(l10n.homeArrange),
actions: [
TextButton(
onPressed: () => setState(() => _arranging = false),
child: Text(l10n.homeDone),
),
const SizedBox(width: 8),
],
)
: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.homeTitle),
actions: [
if (loaded != null && today != null)
StreakChip(
streak: loaded.streak,
week: weekOf(
today,
loaded.activity,
loaded.restDays,
loaded.now,
),
today: today,
),
IconButton(
icon: const SyncTroubleBadge(
child: Icon(LucideIcons.settings),
),
tooltip: l10n.settingsTitle,
onPressed: () => context.push(RoutePaths.settings),
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
const AppMismatchBanner(),
Expanded(
child: loaded == null || today == null
? const Center(child: AppLoadingIndicator())
: _dashboard(loaded, today),
),
],
),
);
Widget _dashboard(HomeStateLoaded state, DateTime today) {
final l10n = AppLocalizations.of(context);
final selected = _selected ?? today;

View file

@ -15,11 +15,14 @@ import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.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/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
@ -68,7 +71,9 @@ import 'package:material_ui/material_ui.dart';
/// the app bar. Below that, the topics are in a drawer, the description
/// sits above the flashcards, and Study and Add material float at the
/// bottom, with the button that opens the topics, sliding away while
/// reading onwards. The licence, if any, is the last line.
/// reading onwards. The licence, if any, is the last line. The studyset's
/// colour, its own or the nearest above it, washes down from the top
/// ([TopGradient]); cobalt where none has one.
class const StudysetScreen({
/// The studyset.
required final Studyset studyset,
@ -267,129 +272,152 @@ class _StudysetViewState() extends State<_StudysetView> {
final description = studyset.content.description.isBlank
? null
: _Description(studyset: studyset);
final canGoBack = context.canPop();
final color =
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary;
return ScrollHidingFab(
fab: wide
? null
: _StudyBar(studyset: studyset, addMaterial: _addMaterial),
builder: (context, fab) => Scaffold(
backgroundColor: theme.colorScheme.surfaceContainerLow,
// The topics' home below expanded windows.
drawer: wide ? null : Drawer(child: SafeArea(child: rail)),
appBar: AppBar(
// A drawer would take the back button's place; the bar at the
// bottom opens the topics instead.
leading: !wide && canGoBack ? const BackButton() : null,
title: TitleWithKicker(
title: studyset.content.title,
kicker: studyset.content.secondaryTitle,
titleMaxLines: 1,
titleStyle:
theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
),
actions: [
if (wide) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 48,
child: _StudyButton(studyset: studyset, inAppBar: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _AddMaterial(
studysetId: studyset.id,
controller: _addMaterial,
builder: (context, menu) => FilledButton.tonalIcon(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
label: Text(
AppLocalizations.of(context).studysetAddMaterial,
),
),
),
),
],
builder: (context, fab) => TopGradient(
color: color,
background: theme.colorScheme.surfaceContainerLow,
child: _scaffold(context, fab, rail, content, description),
),
);
}
Widget _scaffold(
BuildContext context,
Widget? fab,
Widget rail,
Widget content,
Widget? description,
) {
final studyset = widget.studyset;
final all = widget.all;
final theme = Theme.of(context);
final wide = context.windowSize.isAtLeastExpanded;
final canGoBack = context.canPop();
return Scaffold(
backgroundColor: Colors.transparent,
// The topics' home below expanded windows.
drawer: wide ? null : Drawer(child: SafeArea(child: rail)),
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
// A drawer would take the back button's place; the bar at the
// bottom opens the topics instead.
leading: !wide && canGoBack ? const BackButton() : null,
title: TitleWithKicker(
title: studyset.content.title,
kicker: studyset.content.secondaryTitle,
titleMaxLines: 1,
titleStyle:
theme.appBarTheme.titleTextStyle ?? theme.textTheme.titleLarge,
),
actions: [
if (wide) ...[
Padding(
padding: wide
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: ShareButton(studyset: studyset, all: all),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 48,
child: _StudyButton(studyset: studyset, inAppBar: true),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _Options(studyset: studyset, all: all),
),
],
),
floatingActionButton: fab,
// Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StudysetBreadcrumb(
ancestors: ancestorsOf(studyset.id, all),
current: studyset,
),
CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SectionCard(child: description),
child: _AddMaterial(
studysetId: studyset.id,
controller: _addMaterial,
builder: (context, menu) => FilledButton.tonalIcon(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.plus),
label: Text(
AppLocalizations.of(context).studysetAddMaterial,
),
),
),
Expanded(
child: wide
? Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 420,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (description != null) ...[
SectionCard(child: description),
const SizedBox(height: 12),
],
Expanded(
child: SectionCard(
padding: const EdgeInsets.only(bottom: 12),
child: rail,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
],
),
)
: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
),
LicenseFooter(content: studyset.content),
],
),
Padding(
padding: wide
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: ShareButton(studyset: studyset, all: all),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _Options(studyset: studyset, all: all),
),
],
),
floatingActionButton: fab,
// Above the licence line, which it would cover otherwise.
floatingActionButtonLocation: LicenseFooter.shows(studyset.content)
? const _RaisedCenterFloat(_licenceClearance)
: FloatingActionButtonLocation.centerFloat,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StudysetBreadcrumb(
ancestors: ancestorsOf(studyset.id, all),
current: studyset,
),
CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SectionCard(child: description),
),
Expanded(
child: wide
? Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 420,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (description != null) ...[
SectionCard(child: description),
const SizedBox(height: 12),
],
Expanded(
child: SectionCard(
padding: const EdgeInsets.only(bottom: 12),
child: rail,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
],
),
)
: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: SectionCard(
padding: EdgeInsets.zero,
child: content,
),
),
),
LicenseFooter(content: studyset.content),
],
),
);
}

View file

@ -0,0 +1,95 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.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/top_gradient.dart';
import 'package:material_ui/material_ui.dart';
void main() {
group('The tone at the top', () {
for (final brightness in Brightness.values) {
final scheme = appTheme(brightness).colorScheme;
for (final background in [scheme.surface, scheme.surfaceContainerLow]) {
test('keeps the text readable on every palette colour, '
'${brightness.name}, $background', () {
for (final color in PaletteColor.values) {
final top = TopGradient.topTone(
scheme,
color.background,
background,
);
expect(
TopGradient.contrast(top, scheme.onSurface),
greaterThanOrEqualTo(TopGradient.minContrast),
reason: color.name,
);
}
});
}
}
test('is a deep tone 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),
);
expect(
TopGradient.topTone(light, Brand.primary, light.surface),
Color.lerp(light.surface, Brand.primary, TopGradient.lightMix),
);
});
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);
final top = TopGradient.topTone(dark, yellow, dark.surface);
expect(top, isNot(Color.lerp(dark.surface, yellow, TopGradient.darkMix)));
expect(
TopGradient.contrast(top, dark.onSurface),
greaterThanOrEqualTo(TopGradient.minContrast),
);
});
});
testWidgets('fades from the tone into the background below the status bar', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.dark),
home: MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.only(top: 24)),
child: Builder(
builder: (context) => TopGradient(
color: Brand.primary,
background: Theme.of(context).colorScheme.surface,
extent: 100,
child: const SizedBox.expand(),
),
),
),
),
);
final box = tester.widget<DecoratedBox>(
find.descendant(
of: find.byType(TopGradient),
matching: find.byType(DecoratedBox),
),
);
final gradient =
(box.decoration as BoxDecoration).gradient! as LinearGradient;
final scheme = appTheme(Brightness.dark).colorScheme;
expect(gradient.colors, [
TopGradient.topTone(scheme, Brand.primary, scheme.surface),
scheme.surface,
]);
expect(tester.getSize(find.byWidget(box)).height, 124);
});
}

View file

@ -1,5 +1,7 @@
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/theme/brand.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/home/widget/goal_card.dart';
@ -61,6 +63,21 @@ void main() {
);
}
testWidgets('cobalt washes down from the top, behind a clear app bar', (
tester,
) async {
await showHome(tester);
expect(
tester.widget<TopGradient>(find.byType(TopGradient)).color,
Brand.primary,
);
expect(
tester.widget<AppBar>(find.byType(AppBar)).backgroundColor,
Colors.transparent,
);
});
testWidgets('opens with the goal first, then the Pomodoro timer, each on '
'a card', (tester) async {
await showHome(tester);

View file

@ -3,9 +3,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.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/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.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/learning_path/data/learning_path_repository.dart';
@ -103,12 +106,14 @@ void main() {
List<Source> bibliography = const [],
String? frontLanguage,
String? backLanguage,
String? colorName,
}) => gateway.receiveRemoteChange(
id,
encodeStudysetPayload(
StudysetContent(
title: title,
description: description ?? RichContent.empty(),
colorName: colorName,
license: license,
licenseHolder: licenseHolder,
licenseYear: licenseYear,
@ -156,6 +161,39 @@ void main() {
);
}
group('Its colour washes down from the top', () {
Color wash(WidgetTester tester) => tester
.widget<TopGradient>(
find.descendant(
of: find.byType(StudysetScreen),
matching: find.byType(TopGradient),
),
)
.color;
testWidgets('its own', (tester) async {
stored('japanese', 'Japanese', colorName: 'fern');
await showApp(tester, location: RoutePaths.studyset('japanese'));
expect(wash(tester), PaletteColor.fern.background);
});
testWidgets('the nearest one above it', (tester) async {
stored('languages', 'Languages', colorName: 'plum');
stored('japanese', 'Japanese', parent: 'languages');
await showApp(tester, location: RoutePaths.studyset('japanese'));
expect(wash(tester), PaletteColor.plum.background);
});
testWidgets('cobalt where none has one', (tester) async {
stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('japanese'));
expect(wash(tester), Brand.primary);
});
});
testWidgets('shows where a studyset sits, what it is about, its topics and '
'its flashcards', (tester) async {
stored('japanese', 'Japanese');