feat(learn): a split of Topics, Introduction and Bibliography beside the material

As Tomorrow University's challenges have it (the developer's decisions of
10 Nov 2026): beside a studyset's material, one at a time, its topics,
its introduction (the description, whole; left out where there is none)
and its bibliography, read and changed in place. In the side column from
expanded windows on, in the topics' sheet below; it opens on Topics, at
every level. The description no longer stands above the material, nor
the bibliography's button at the foot of the topics. The pill between
the material's parts becomes PillSwitch, shared by both.

Whats-New: A studyset's topics, introduction and bibliography now share one panel beside its material, a tap apart.
Whats-New-De: Themen, Einführung und Literatur eines Lernsets teilen sich jetzt ein Feld neben dem Material, einen Tipp voneinander entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 20:02:42 +01:00
commit bed39dc2a2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 369 additions and 363 deletions

File diff suppressed because one or more lines are too long

View file

@ -2171,5 +2171,6 @@
"libraryShelfStarred": "Mit Stern", "libraryShelfStarred": "Mit Stern",
"libraryShelfLastWeek": "Letzte 7 Tage", "libraryShelfLastWeek": "Letzte 7 Tage",
"libraryShelfLastMonth": "Letzter Monat", "libraryShelfLastMonth": "Letzter Monat",
"libraryShelfOlder": "Älter" "libraryShelfOlder": "Älter",
"studysetSideIntroduction": "Einführung"
} }

View file

@ -2668,5 +2668,7 @@
"libraryShelfLastMonth": "Last month", "libraryShelfLastMonth": "Last month",
"@libraryShelfLastMonth": {"description": "The library's heading over the studysets studied or changed in the last 30 days, before the last 7."}, "@libraryShelfLastMonth": {"description": "The library's heading over the studysets studied or changed in the last 30 days, before the last 7."},
"libraryShelfOlder": "Older", "libraryShelfOlder": "Older",
"@libraryShelfOlder": {"description": "The library's heading over the studysets studied or changed longer ago."} "@libraryShelfOlder": {"description": "The library's heading over the studysets studied or changed longer ago."},
"studysetSideIntroduction": "Introduction",
"@studysetSideIntroduction": {"description": "The part of the split beside a studyset's material showing its description, as Tomorrow University names it."}
} }

View file

@ -12811,6 +12811,12 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Older'** /// **'Older'**
String get libraryShelfOlder; String get libraryShelfOlder;
/// The part of the split beside a studyset's material showing its description, as Tomorrow University names it.
///
/// In en, this message translates to:
/// **'Introduction'**
String get studysetSideIntroduction;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -8188,4 +8188,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryShelfOlder => 'Älter'; String get libraryShelfOlder => 'Älter';
@override
String get studysetSideIntroduction => 'Einführung';
} }

View file

@ -8115,6 +8115,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryShelfOlder => 'Older'; String get libraryShelfOlder => 'Older';
@override
String get studysetSideIntroduction => 'Introduction';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,7 +1,6 @@
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';
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart'; import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
@ -397,32 +396,6 @@ Future<void> changeStudyOptions(BuildContext context, Studyset studyset) async {
} }
} }
/// Shows [studyset]'s bibliography, to be read and changed; each change is
/// kept as it happens, on top of the studyset as it is by then.
Future<void> showBibliography(BuildContext context, Studyset studyset) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>();
return showSourcesSheet(
context,
title: l10n.studysetBibliography,
intro: l10n.studysetBibliographyIntro,
sources: studyset.content.bibliography,
onChanged: (bibliography) {
final current = switch (bloc.state) {
StudysetListStateLoaded(:final studysets) =>
studysets.where((each) => each.id == studyset.id).firstOrNull,
_ => null,
};
bloc.add(
StudysetListEventContentChanged(
studyset.id,
(current ?? studyset).content.withBibliography(bibliography),
),
);
},
);
}
/// Asks what of [studyset]'s progress, with everything inside it, starts /// Asks what of [studyset]'s progress, with everything inside it, starts
/// afresh (its flashcards; reading its PDFs and its learning paths, where /// afresh (its flashcards; reading its PDFs and its learning paths, where
/// they have progress), then starts it afresh and offers to undo that for a /// they have progress), then starts it afresh and offers to undo that for a

View file

@ -56,14 +56,39 @@ class const StudysetPartSwitch({
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
/// How high it is. /// How high it is.
static const height = 44.0; static const height = PillSwitch.height;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return PillSwitch<StudysetPart>(
values: parts,
current: current,
label: (part) => studysetPartName(l10n, part),
onChosen: onChosen,
);
}
}
/// Buttons between [values], one pill, the [current] one raised in it, each
/// named by [label]: the switch between a studyset's parts, and between
/// what stands beside them (`StudysetSidePanel`). Choosing another ticks, as
/// the navigation bar does; the current one again is [onChosen] too.
class const PillSwitch<T>({
required final List<T> values,
required final T current,
required final String Function(T value) label,
required final ValueChanged<T> onChosen,
super.key,
}) extends StatelessWidget {
/// How high it is.
static const height = 44.0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final index = math.max(parts.indexOf(current), 0); final index = math.max(values.indexOf(current), 0);
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480), constraints: const BoxConstraints(maxWidth: 480),
@ -76,7 +101,7 @@ class const StudysetPartSwitch({
), ),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final width = constraints.maxWidth / parts.length; final width = constraints.maxWidth / values.length;
return Stack( return Stack(
children: [ children: [
AnimatedPositioned( AnimatedPositioned(
@ -104,7 +129,7 @@ class const StudysetPartSwitch({
), ),
Row( Row(
children: [ children: [
for (final part in parts) for (final part in values)
Expanded( Expanded(
child: Semantics( child: Semantics(
button: true, button: true,
@ -120,7 +145,7 @@ class const StudysetPartSwitch({
}, },
child: Center( child: Center(
child: Text( child: Text(
studysetPartName(l10n, part), label(part),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(

View file

@ -25,8 +25,8 @@ import 'package:material_ui/material_ui.dart';
/// those with topics of their own unfold them beneath, at any depth, each /// those with topics of their own unfold them beneath, at any depth, each
/// opening with the screens of the levels between beneath it /// opening with the screens of the levels between beneath it
/// ([openStudysetWithin]). A switch rearranges the topics by dragging. New sub-studyset stays at the foot, /// ([openStudysetWithin]). A switch rearranges the topics by dragging. New sub-studyset stays at the foot,
/// across from [footEnd], with or without topics, so the foot looks the same /// with or without topics, so the foot looks the same in every studyset
/// in every studyset (decided 2026-10-08). Without topics, a word on what /// (decided 2026-10-08). Without topics, a word on what
/// they are for in their place. /// they are for in their place.
class const StudysetRail({ class const StudysetRail({
/// The studyset whose topics these are. /// The studyset whose topics these are.
@ -38,9 +38,6 @@ class const StudysetRail({
/// Opens Add material, for "All topics" while nothing is due. /// Opens Add material, for "All topics" while nothing is due.
final VoidCallback? onAddMaterial, final VoidCallback? onAddMaterial,
/// What the foot ends on, across from New sub-studyset.
final Widget? footEnd,
/// Opens a topic, at any depth, in place of the rail: in the topics' /// Opens a topic, at any depth, in place of the rail: in the topics'
/// sheet, which closes first. The rail opens it itself without. /// sheet, which closes first. The rail opens it itself without.
final void Function(Studyset topic)? onOpen, final void Function(Studyset topic)? onOpen,
@ -158,7 +155,6 @@ class _StudysetRailState() extends State<StudysetRail> {
}; };
final addMaterial = widget.onAddMaterial; final addMaterial = widget.onAddMaterial;
final newSubStudyset = state is StudysetListStateLoaded && !_reordering; final newSubStudyset = state is StudysetListStateLoaded && !_reordering;
final footEnd = widget.footEnd;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -299,9 +295,8 @@ class _StudysetRailState() extends State<StudysetRail> {
}, },
), ),
// At the foot, below a line, however long the list: New // At the foot, below a line, however long the list: New
// sub-studyset once the topics are read, and what the rail is // sub-studyset once the topics are read.
// given to end on. if (newSubStudyset)
if (newSubStudyset || footEnd != null)
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
@ -310,29 +305,18 @@ class _StudysetRailState() extends State<StudysetRail> {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row( // Shortened only in a narrow drawer.
children: [ child: Align(
// The button takes the width the end leaves, not alignment: AlignmentDirectional.centerStart,
// half of it, and shortens only in a narrow drawer. child: FilledButton.tonalIcon(
if (newSubStudyset) onPressed: _create,
Expanded( icon: const Icon(LucideIcons.plus),
child: Align( label: Text(
alignment: AlignmentDirectional.centerStart, l10n.studysetNewSubStudyset,
child: FilledButton.tonalIcon( maxLines: 1,
onPressed: _create, overflow: TextOverflow.ellipsis,
icon: const Icon(LucideIcons.plus), ),
label: Text( ),
l10n.studysetNewSubStudyset,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
)
else
const Spacer(),
?footEnd,
],
), ),
), ),
), ),

View file

@ -2,13 +2,11 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.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'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
@ -43,6 +41,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_bread
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_side_panel.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.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';
@ -50,10 +49,11 @@ import 'package:material_ui/material_ui.dart';
/// A studyset's screen: where it sits, what it is about, its topics and its /// A studyset's screen: where it sits, what it is about, its topics and its
/// flashcards, and what can be done with it. /// flashcards, and what can be done with it.
/// ///
/// From expanded windows on, two columns: the description over the topics /// From expanded windows on, two columns: on the left a split of the
/// on the left, the flashcards on the right, and Study and Add material in /// topics, the introduction and the bibliography ([StudysetSidePanel]), the
/// the app bar. Below that, the topics open in a sheet from the end of the /// flashcards on the right, and Study and Add material in the app bar.
/// path's line, the description sits above the flashcards, and Study and /// Below that, the split opens in a sheet from the end of the path's line,
/// and Study and
/// Add material float at the bottom where the navigation bar steps aside, /// Add material float at the bottom where the navigation bar steps aside,
/// sliding away while reading onwards (decided 2026-10-11: no drawer, whose /// sliding away while reading onwards (decided 2026-10-11: no drawer, whose
/// swipe from the edge is iOS's way back). The licence, if any, is the last line. The studyset's /// swipe from the edge is iOS's way back). The licence, if any, is the last line. The studyset's
@ -266,18 +266,17 @@ class _StudysetViewState() extends State<_StudysetView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final studyset = widget.studyset; final studyset = widget.studyset;
final all = widget.all; final all = widget.all;
// The topics, with the bibliography at their foot across from New // The topics, the introduction and the bibliography, one at a time
// sub-studyset: in the side bar from expanded windows on, below that in // (decided by the developer, 10 Nov 2026): in the side bar from
// a sheet ([_showTopics]). // expanded windows on, below that in a sheet ([_showTopics]).
final rail = StudysetRail( final rail = StudysetSidePanel(
parent: studyset, studyset: studyset,
all: all, topics: StudysetRail(
onAddMaterial: _addMaterial.open, parent: studyset,
footEnd: _BibliographyButton(studyset: studyset), all: all,
onAddMaterial: _addMaterial.open,
),
); );
final description = studyset.content.description.isBlank
? null
: _Description(studyset: studyset);
final color = final color =
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ?? PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary; Brand.primary;
@ -289,7 +288,6 @@ class _StudysetViewState() extends State<_StudysetView> {
rail, rail,
_Content(studysetId: studyset.id, parts: parts), _Content(studysetId: studyset.id, parts: parts),
parts, parts,
description,
color, color,
), ),
); );
@ -302,7 +300,6 @@ class _StudysetViewState() extends State<_StudysetView> {
Widget rail, Widget rail,
Widget content, Widget content,
_PartsView parts, _PartsView parts,
Widget? description,
Color color, Color color,
) { ) {
final studyset = widget.studyset; final studyset = widget.studyset;
@ -362,7 +359,6 @@ class _StudysetViewState() extends State<_StudysetView> {
rail, rail,
content, content,
parts, parts,
description,
width: constraints.maxWidth, width: constraints.maxWidth,
), ),
), ),
@ -390,8 +386,7 @@ class _StudysetViewState() extends State<_StudysetView> {
Widget? fab, Widget? fab,
Widget rail, Widget rail,
Widget content, Widget content,
_PartsView parts, _PartsView parts, {
Widget? description, {
required double width, required double width,
}) { }) {
final roomy = width >= _roomyWidth; final roomy = width >= _roomyWidth;
@ -532,11 +527,6 @@ class _StudysetViewState() extends State<_StudysetView> {
), ),
), ),
CopyUpdateBanner(studyset: studyset), CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: SectionCard(child: description),
),
Expanded( Expanded(
child: wide child: wide
? Padding( ? Padding(
@ -551,21 +541,10 @@ class _StudysetViewState() extends State<_StudysetView> {
children: [ children: [
SizedBox( SizedBox(
width: _sideColumnWidth, width: _sideColumnWidth,
child: Column( child: SectionCard(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.only(bottom: 12),
children: [ scrolls: true,
if (description != null) ...[ child: rail,
SectionCard(child: description),
const SizedBox(height: 12),
],
Expanded(
child: SectionCard(
padding: const EdgeInsets.only(bottom: 12),
scrolls: true,
child: rail,
),
),
],
), ),
), ),
const SizedBox(width: _gutter), const SizedBox(width: _gutter),
@ -645,13 +624,15 @@ class _StudysetViewState() extends State<_StudysetView> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
body: SafeArea( body: SafeArea(
top: false, top: false,
child: StudysetRail( child: StudysetSidePanel(
parent: studyset, studyset: studyset,
all: all, topics: StudysetRail(
onAddMaterial: () => choose(const _AddMaterialChoice()), parent: studyset,
onOpen: (topic) => choose(_OpenTopic(topic)), all: all,
onStudy: (chosen) => choose(_StudyTopic(chosen)), onAddMaterial: () => choose(const _AddMaterialChoice()),
footEnd: _BibliographyButton(studyset: studyset), onOpen: (topic) => choose(_OpenTopic(topic)),
onStudy: (chosen) => choose(_StudyTopic(chosen)),
),
), ),
), ),
), ),
@ -677,36 +658,6 @@ class _StudysetViewState() extends State<_StudysetView> {
} }
} }
/// The bibliography, as an outlined round button at the foot of the topics.
class const _BibliographyButton({required final Studyset studyset})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Tooltip(
message: AppLocalizations.of(context).studysetBibliography,
child: Material(
color: Colors.transparent,
shape: StadiumBorder(
side: BorderSide(color: theme.colorScheme.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => showBibliography(context, studyset),
child: SizedBox.square(
dimension: 48,
child: Icon(
LucideIcons.libraryBig,
size: 18,
color: theme.colorScheme.primary,
),
),
),
),
);
}
}
/// Which part of the studyset shows, as the switch above it and the content /// Which part of the studyset shows, as the switch above it and the content
/// share it: whether that is settled yet, the parts there are, the one /// share it: whether that is settled yet, the parts there are, the one
/// shown, each part's scrolling, and how to show another. /// shown, each part's scrolling, and how to show another.
@ -925,149 +876,6 @@ class const _Content({
} }
} }
/// The studyset's description, the width of readable text. Taller than a
/// few lines, it is cut short, fading out, and the whole of it is a tap
/// away: on what shows, or on Read more below it.
class const _Description({required final Studyset studyset})
extends StatefulWidget {
/// How tall the description shows before it is cut short.
static const maxPreviewHeight = 108.0;
@override
State<_Description> createState() => _DescriptionState();
}
class _DescriptionState() extends State<_Description> {
/// Whether the description is taller than the preview; null until
/// measured.
bool? _overflows;
final _measured = GlobalKey();
@override
void didUpdateWidget(_Description oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.studyset.content.description !=
widget.studyset.content.description) {
_overflows = null;
}
}
/// Measures the description as laid out whole, once the frame is done.
void _measureAfterFrame() => WidgetsBinding.instance.addPostFrameCallback((
_,
) {
if (!mounted) {
return;
}
if (_measured.currentContext?.findRenderObject() case final RenderBox box) {
final overflows = box.size.height > _Description.maxPreviewHeight;
if (overflows != _overflows) {
setState(() => _overflows = overflows);
}
}
});
void _showWhole(BuildContext context) {
final content = widget.studyset.content;
unawaited(
showAdaptiveSheet<void>(
context,
builder: (context) => SheetScaffold(
title: content.title,
heightFactor: 0.7,
body: Padding(
padding: const EdgeInsets.only(top: 12, bottom: 24),
child: LearnContentView(
content: content.description,
textStyle: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final view = ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: LearnContentView(
content: widget.studyset.content.description,
textStyle: Theme.of(context).textTheme.bodyMedium,
),
);
_measureAfterFrame();
if (_overflows != true) {
// Measured again whenever it changes size, as when the editor's view
// has laid its document out. Laid out whole but shown no taller than
// the preview until measured, so a long one never takes more room
// for a frame than the screen has.
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
_measureAfterFrame();
return true;
},
child: ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: _Description.maxPreviewHeight,
),
child: OverflowBox(
alignment: Alignment.topLeft,
maxHeight: double.infinity,
fit: OverflowBoxFit.deferToChild,
child: SizeChangedLayoutNotifier(key: _measured, child: view),
),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showWhole(context),
child: ClipRect(
child: ShaderMask(
// Fades out the last lines into the card.
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.black, Colors.transparent],
stops: [0, 0.75, 1],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: SizedBox(
height: _Description.maxPreviewHeight,
child: OverflowBox(
alignment: Alignment.topLeft,
maxHeight: double.infinity,
child: view,
),
),
),
),
),
const SizedBox(height: 4),
TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
alignment: Alignment.centerLeft,
),
onPressed: () => _showWhole(context),
child: Text(AppLocalizations.of(context).studysetReadMore),
),
],
);
}
}
/// Studying the studyset, everything inside it, as a pill: "Learn 12 /// Studying the studyset, everything inside it, as a pill: "Learn 12
/// flashcards" ([inAppBar]) or "Learn 12" (floating) while cards are due, /// flashcards" ([inAppBar]) or "Learn 12" (floating) while cards are due,
/// "Study" otherwise; off, saying why, with nothing to study (an archived /// "Study" otherwise; off, saying why, with nothing to study (an archived

View file

@ -0,0 +1,167 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// What stands beside a studyset's material, one at a time.
enum StudysetSide() {
/// Its topics.
topics,
/// Its description.
introduction,
/// The works it cites.
bibliography,
}
/// Beside a studyset's material, a split of its topics, its introduction
/// and its bibliography, as Tomorrow University's challenges have it
/// (decided by the developer, 10 Nov 2026): in the side column from
/// expanded windows on, in the topics' sheet below. It opens on the topics;
/// a studyset without a description has no introduction to show. Every
/// level shows it, each its own introduction and bibliography.
class const StudysetSidePanel({
/// The studyset.
required final Studyset studyset,
/// Its topics, as the rail lists them.
required final Widget topics,
super.key,
}) extends StatefulWidget {
/// The key of the split's switch.
static const switchKey = ValueKey('studyset-side-switch');
@override
State<StudysetSidePanel> createState() => _StudysetSidePanelState();
}
class _StudysetSidePanelState() extends State<StudysetSidePanel> {
var _shown = StudysetSide.topics;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final studyset = widget.studyset;
final sides = [
StudysetSide.topics,
if (!studyset.content.description.isBlank) StudysetSide.introduction,
StudysetSide.bibliography,
];
// One gone (its description emptied) shows the topics again.
final shown = sides.contains(_shown) ? _shown : StudysetSide.topics;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: PillSwitch<StudysetSide>(
key: StudysetSidePanel.switchKey,
values: sides,
current: shown,
label: (side) => switch (side) {
StudysetSide.topics => l10n.studysetTopics,
StudysetSide.introduction => l10n.studysetSideIntroduction,
StudysetSide.bibliography => l10n.studysetBibliography,
},
onChosen: (side) => setState(() => _shown = side),
),
),
Expanded(
child: switch (shown) {
StudysetSide.topics => widget.topics,
StudysetSide.introduction => _Introduction(studyset: studyset),
StudysetSide.bibliography => _Bibliography(studyset: studyset),
},
),
],
);
}
}
/// The studyset's description, whole, scrolling.
class const _Introduction({required final Studyset studyset})
extends StatelessWidget {
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: LearnContentView(
content: studyset.content.description,
textStyle: Theme.of(context).textTheme.bodyMedium,
),
);
}
/// The works the studyset cites, to be read and, behind the edit button,
/// changed; each change is kept as it happens, on top of the studyset as
/// it is by then.
class const _Bibliography({required final Studyset studyset})
extends StatefulWidget {
@override
State<_Bibliography> createState() => _BibliographyState();
}
class _BibliographyState() extends State<_Bibliography> {
/// Changing it: from the start where there is nothing yet to read.
late var _editing = widget.studyset.content.bibliography.isEmpty;
void _save(List<Source> bibliography) {
final bloc = context.read<StudysetListBloc>();
final current = switch (bloc.state) {
StudysetListStateLoaded(:final studysets) =>
studysets.where((each) => each.id == widget.studyset.id).firstOrNull,
_ => null,
};
bloc.add(
StudysetListEventContentChanged(
widget.studyset.id,
(current ?? widget.studyset).content.withBibliography(bibliography),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.studysetBibliographyIntro,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
IconButton(
tooltip: _editing ? l10n.sourcesEditDone : l10n.sourcesEdit,
isSelected: _editing,
icon: const Icon(LucideIcons.pencil),
selectedIcon: const Icon(LucideIcons.check),
onPressed: () => setState(() => _editing = !_editing),
),
],
),
const SizedBox(height: 12),
SourceList(
sources: widget.studyset.content.bibliography,
onChanged: _editing ? _save : null,
),
],
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_side_panel.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -138,6 +139,14 @@ void main() {
studyButtonBackFirstKey: true, studyButtonBackFirstKey: true,
}); });
await showApp(tester, RoutePaths.studyset('lesson')); await showApp(tester, RoutePaths.studyset('lesson'));
// The description is the split's Introduction (decided 10 Nov 2026).
await tester.tap(
find.descendant(
of: find.byKey(StudysetSidePanel.switchKey),
matching: find.text('Introduction'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Verbs')); await tester.tap(find.widgetWithText(FilledButton, 'Verbs'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -150,6 +159,14 @@ void main() {
testWidgets('a button whose studyset is gone says so', (tester) async { testWidgets('a button whose studyset is gone says so', (tester) async {
describedWithButton({studyButtonStudysetKey: 'gone'}); describedWithButton({studyButtonStudysetKey: 'gone'});
await showApp(tester, RoutePaths.studyset('lesson')); await showApp(tester, RoutePaths.studyset('lesson'));
// The description is the split's Introduction (decided 10 Nov 2026).
await tester.tap(
find.descendant(
of: find.byKey(StudysetSidePanel.switchKey),
matching: find.text('Introduction'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Study')); await tester.tap(find.widgetWithText(FilledButton, 'Study'));
await tester.pump(); await tester.pump();

View file

@ -36,6 +36,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navig
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.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';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_side_panel.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.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';
@ -152,6 +153,19 @@ void main() {
/// Opens the studyset titled [title] from the library, so its screen has /// Opens the studyset titled [title] from the library, so its screen has
/// the library below it. /// the library below it.
/// The part [name] of the split beside the material (Topics,
/// Introduction, Bibliography).
Finder sidePart(String name) => find.descendant(
of: find.byKey(StudysetSidePanel.switchKey),
matching: find.text(name),
);
/// Shows the part [name] of the split beside the material.
Future<void> chooseSide(WidgetTester tester, String name) async {
await tester.tap(sidePart(name));
await tester.pumpAndSettle();
}
Future<void> openFromLibrary( Future<void> openFromLibrary(
WidgetTester tester, WidgetTester tester,
String title, { String title, {
@ -349,19 +363,26 @@ void main() {
expect(step('Japanese'), findsOneWidget); expect(step('Japanese'), findsOneWidget);
expect(step('Kana'), findsOneWidget); expect(step('Kana'), findsOneWidget);
expect(step('›'), findsNWidgets(2)); expect(step('›'), findsNWidgets(2));
// What it is about is a part of the split beside the material, which
// opens on the topics (the developer's decisions of 10 Nov 2026).
expect( expect(
find.textContaining('The two syllabaries.', findRichText: true), find.textContaining('The two syllabaries.', findRichText: true),
findsOneWidget, findsNothing,
); );
// Short, it shows whole. expect(sidePart('Topics'), findsOneWidget);
expect(find.text('Read more'), findsNothing);
expect(find.text('Topics'), findsOneWidget);
expect(find.text('Hiragana'), findsOneWidget); expect(find.text('Hiragana'), findsOneWidget);
expect(find.text('All topics'), findsOneWidget); expect(find.text('All topics'), findsOneWidget);
// What the topic and all topics hold: nothing. // What the topic and all topics hold: nothing.
expect(find.text('Empty'), findsNWidgets(2)); expect(find.text('Empty'), findsNWidgets(2));
expect(find.text('Flashcards'), findsOneWidget); expect(find.text('Flashcards'), findsOneWidget);
expect(find.text('No flashcards yet'), findsOneWidget); expect(find.text('No flashcards yet'), findsOneWidget);
await chooseSide(tester, 'Introduction');
expect(
find.textContaining('The two syllabaries.', findRichText: true),
findsOneWidget,
);
expect(find.text('Hiragana'), findsNothing);
}); });
testWidgets('a step of the way leads up there, and back leads further ' testWidgets('a step of the way leads up there, and back leads further '
@ -618,11 +639,11 @@ void main() {
expect(find.byTooltip('Copy'), findsNWidgets(2)); expect(find.byTooltip('Copy'), findsNWidgets(2));
}); });
testWidgets('the bibliography is a button at the foot of the topics, not ' testWidgets('beside the material, a split of Topics, Introduction and '
"in the app bar: in the side bar, and on a phone in the topics' sheet", ( "Bibliography, as Tomorrow University's challenges (the developer's "
tester, 'decisions of 10 Nov 2026): in the side column, and on a phone in the '
) async { "topics' sheet; not in the app bar", (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese', description: paragraph('Learn it all.'));
await openFromLibrary(tester, 'Japanese'); await openFromLibrary(tester, 'Japanese');
expect( expect(
find.descendant( find.descendant(
@ -631,50 +652,69 @@ void main() {
), ),
findsNothing, findsNothing,
); );
for (final name in ['Topics', 'Introduction', 'Bibliography']) {
expect(sidePart(name), findsOneWidget);
}
expect( expect(
find.descendant( tester.getTopLeft(sidePart('Topics')).dx,
of: find.byType(StudysetRail), lessThan(tester.getTopLeft(sidePart('Introduction')).dx),
matching: find.byTooltip('Bibliography'),
),
findsOneWidget,
); );
expect(find.byType(StudysetRail), findsOneWidget, reason: 'on Topics');
await showApp( await showApp(
tester, tester,
location: RoutePaths.studyset('japanese'), location: RoutePaths.studyset('japanese'),
width: 400, width: 400,
); );
expect(find.byTooltip('Bibliography'), findsNothing); expect(find.byKey(StudysetSidePanel.switchKey), findsNothing);
expect(
find.textContaining('Learn it all.', findRichText: true),
findsNothing,
reason: 'no longer above the material',
);
await tester.tap(find.byTooltip('Show topics')); await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
find.descendant( find.descendant(
of: find.byType(BottomSheet), of: find.byType(BottomSheet),
matching: find.byTooltip('Bibliography'), matching: find.byKey(StudysetSidePanel.switchKey),
), ),
findsOneWidget, findsOneWidget,
); );
await chooseSide(tester, 'Introduction');
expect(
find.textContaining('Learn it all.', findRichText: true),
findsOneWidget,
);
}); });
testWidgets('in the side bar, the bibliography shares the foot of the ' testWidgets('without a description, the split leaves Introduction out', (
'topics with New sub-studyset: that at the start, the bibliography at ' tester,
'the end', (tester) async { ) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await showApp(tester, location: RoutePaths.studyset('japanese')); await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final rail = tester.getRect(find.byType(StudysetRail)); expect(sidePart('Topics'), findsOneWidget);
final create = tester.getRect( expect(sidePart('Introduction'), findsNothing);
find.ancestor( expect(sidePart('Bibliography'), findsOneWidget);
of: find.text('New sub-studyset'), });
matching: find.byWidgetPredicate((widget) => widget is FilledButton),
), testWidgets('a topic shows the split too: its own introduction, and the '
'bibliography it keeps', (tester) async {
stored('japanese', 'Japanese');
stored(
'kana',
'Kana',
parent: 'japanese',
description: paragraph('The two syllabaries.'),
); );
final bibliography = tester.getRect(find.byTooltip('Bibliography')); await showApp(tester, location: RoutePaths.studyset('kana'));
expect(bibliography.center.dy, moreOrLessEquals(create.center.dy)); await tester.pumpAndSettle();
expect(create.left, moreOrLessEquals(rail.left + 16));
expect(bibliography.right, moreOrLessEquals(rail.right - 16)); for (final name in ['Topics', 'Introduction', 'Bibliography']) {
expect(sidePart(name), findsOneWidget);
}
}); });
testWidgets('in the side bar, New sub-studyset shows whole', ( testWidgets('in the side bar, New sub-studyset shows whole', (
@ -693,8 +733,8 @@ void main() {
expect(label.didExceedMaxLines, isFalse); expect(label.didExceedMaxLines, isFalse);
}); });
testWidgets('without topics, the foot is the same: one New sub-studyset ' testWidgets('without topics, New sub-studyset stands below the empty '
'at its start, the bibliography at its end', (tester) async { 'list, the bibliography no longer at the foot', (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('japanese')); await showApp(tester, location: RoutePaths.studyset('japanese'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -707,40 +747,13 @@ void main() {
matching: find.byWidgetPredicate((widget) => widget is FilledButton), matching: find.byWidgetPredicate((widget) => widget is FilledButton),
), ),
); );
final bibliography = tester.getRect(find.byTooltip('Bibliography')); expect(find.byTooltip('Bibliography'), findsNothing);
expect(bibliography.center.dy, moreOrLessEquals(create.center.dy));
expect( expect(
create.top, create.top,
greaterThan(tester.getRect(find.text('No sub-studysets yet.')).bottom), greaterThan(tester.getRect(find.text('No sub-studysets yet.')).bottom),
); );
}); });
testWidgets('on a phone, the bibliography shares the foot of the topics '
'with New sub-studyset: that at the start, the bibliography at the '
'end', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: 400,
);
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
final sheet = tester.getRect(find.byType(StudysetRail));
final create = tester.getRect(
find.ancestor(
of: find.text('New sub-studyset'),
matching: find.byWidgetPredicate((widget) => widget is FilledButton),
),
);
final bibliography = tester.getRect(find.byTooltip('Bibliography'));
expect(bibliography.center.dy, moreOrLessEquals(create.center.dy));
expect(create.left, moreOrLessEquals(sheet.left + 16));
expect(bibliography.right, moreOrLessEquals(sheet.right - 16));
});
testWidgets('with two paths or more, Reorder beside the tabs lets them be ' testWidgets('with two paths or more, Reorder beside the tabs lets them be '
'dragged, until Done', (tester) async { 'dragged, until Done', (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
@ -1161,10 +1174,8 @@ void main() {
}); });
group('the bibliography', () { group('the bibliography', () {
Future<void> open(WidgetTester tester) async { Future<void> open(WidgetTester tester) =>
await tester.tap(find.byTooltip('Bibliography')); chooseSide(tester, 'Bibliography');
await tester.pumpAndSettle();
}
testWidgets('starts empty, ready for a first source, and keeps what is ' testWidgets('starts empty, ready for a first source, and keeps what is '
'added, cited as the device cites', (tester) async { 'added, cited as the device cites', (tester) async {
@ -1696,8 +1707,8 @@ void main() {
}); });
}); });
testWidgets('a long description is cut short, the whole of it a tap on ' testWidgets('a long description shows whole in Introduction, scrolling '
'Read more away', (tester) async { 'there', (tester) async {
stored( stored(
'grammar', 'grammar',
'Grammar', 'Grammar',
@ -1707,15 +1718,21 @@ void main() {
); );
await showApp(tester, location: RoutePaths.studyset('grammar')); await showApp(tester, location: RoutePaths.studyset('grammar'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Read more')); await chooseSide(tester, 'Introduction');
await tester.pumpAndSettle();
expect( expect(find.text('Read more'), findsNothing);
find.descendant( final text = find.textContaining('Line 40 of', findRichText: true);
of: find.byType(SheetScaffold), await tester.scrollUntilVisible(
matching: find.text('Grammar'), text,
), 200,
findsOneWidget, scrollable: find
.descendant(
of: find.byType(StudysetSidePanel),
matching: find.byType(Scrollable),
)
.first,
); );
expect(text, findsOneWidget);
}); });
testWidgets("the material's tabs say how many each holds, nothing on an " testWidgets("the material's tabs say how many each holds, nothing on an "