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:
parent
a3b124ddcb
commit
bed39dc2a2
10 changed files with 369 additions and 363 deletions
|
|
@ -2171,5 +2171,6 @@
|
|||
"libraryShelfStarred": "Mit Stern",
|
||||
"libraryShelfLastWeek": "Letzte 7 Tage",
|
||||
"libraryShelfLastMonth": "Letzter Monat",
|
||||
"libraryShelfOlder": "Älter"
|
||||
"libraryShelfOlder": "Älter",
|
||||
"studysetSideIntroduction": "Einführung"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2668,5 +2668,7 @@
|
|||
"libraryShelfLastMonth": "Last month",
|
||||
"@libraryShelfLastMonth": {"description": "The library's heading over the studysets studied or changed in the last 30 days, before the last 7."},
|
||||
"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."}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12811,6 +12811,12 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Older'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8188,4 +8188,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get libraryShelfOlder => 'Älter';
|
||||
|
||||
@override
|
||||
String get studysetSideIntroduction => 'Einführung';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8115,6 +8115,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get libraryShelfOlder => 'Older';
|
||||
|
||||
@override
|
||||
String get studysetSideIntroduction => 'Introduction';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.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/dependencies/person_dependencies.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
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -56,14 +56,39 @@ class const StudysetPartSwitch({
|
|||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// How high it is.
|
||||
static const height = 44.0;
|
||||
static const height = PillSwitch.height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext 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 colors = theme.colorScheme;
|
||||
final index = math.max(parts.indexOf(current), 0);
|
||||
final index = math.max(values.indexOf(current), 0);
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
|
|
@ -76,7 +101,7 @@ class const StudysetPartSwitch({
|
|||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth / parts.length;
|
||||
final width = constraints.maxWidth / values.length;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
|
|
@ -104,7 +129,7 @@ class const StudysetPartSwitch({
|
|||
),
|
||||
Row(
|
||||
children: [
|
||||
for (final part in parts)
|
||||
for (final part in values)
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
|
|
@ -120,7 +145,7 @@ class const StudysetPartSwitch({
|
|||
},
|
||||
child: Center(
|
||||
child: Text(
|
||||
studysetPartName(l10n, part),
|
||||
label(part),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// those with topics of their own unfold them beneath, at any depth, each
|
||||
/// 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,
|
||||
/// across from [footEnd], with or without topics, so the foot looks the same
|
||||
/// in every studyset (decided 2026-10-08). Without topics, a word on what
|
||||
/// with or without topics, so the foot looks the same in every studyset
|
||||
/// (decided 2026-10-08). Without topics, a word on what
|
||||
/// they are for in their place.
|
||||
class const StudysetRail({
|
||||
/// The studyset whose topics these are.
|
||||
|
|
@ -38,9 +38,6 @@ class const StudysetRail({
|
|||
/// Opens Add material, for "All topics" while nothing is due.
|
||||
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'
|
||||
/// sheet, which closes first. The rail opens it itself without.
|
||||
final void Function(Studyset topic)? onOpen,
|
||||
|
|
@ -158,7 +155,6 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
};
|
||||
final addMaterial = widget.onAddMaterial;
|
||||
final newSubStudyset = state is StudysetListStateLoaded && !_reordering;
|
||||
final footEnd = widget.footEnd;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
@ -299,9 +295,8 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
},
|
||||
),
|
||||
// At the foot, below a line, however long the list: New
|
||||
// sub-studyset once the topics are read, and what the rail is
|
||||
// given to end on.
|
||||
if (newSubStudyset || footEnd != null)
|
||||
// sub-studyset once the topics are read.
|
||||
if (newSubStudyset)
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
|
|
@ -310,29 +305,18 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// The button takes the width the end leaves, not
|
||||
// half of it, and shortens only in a narrow drawer.
|
||||
if (newSubStudyset)
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _create,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(
|
||||
l10n.studysetNewSubStudyset,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
?footEnd,
|
||||
],
|
||||
// Shortened only in a narrow drawer.
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _create,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(
|
||||
l10n.studysetNewSubStudyset,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.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/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/feature/anki/data/anki_importer.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_parts.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:lucide_icons_flutter/lucide_icons.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
|
||||
/// flashcards, and what can be done with it.
|
||||
///
|
||||
/// From expanded windows on, two columns: the description over the topics
|
||||
/// on the left, the flashcards on the right, and Study and Add material in
|
||||
/// the app bar. Below that, the topics open in a sheet from the end of the
|
||||
/// path's line, the description sits above the flashcards, and Study and
|
||||
/// From expanded windows on, two columns: on the left a split of the
|
||||
/// topics, the introduction and the bibliography ([StudysetSidePanel]), the
|
||||
/// flashcards on the right, and Study and Add material in the app bar.
|
||||
/// 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,
|
||||
/// 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
|
||||
|
|
@ -266,18 +266,17 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget build(BuildContext context) {
|
||||
final studyset = widget.studyset;
|
||||
final all = widget.all;
|
||||
// The topics, with the bibliography at their foot across from New
|
||||
// sub-studyset: in the side bar from expanded windows on, below that in
|
||||
// a sheet ([_showTopics]).
|
||||
final rail = StudysetRail(
|
||||
parent: studyset,
|
||||
all: all,
|
||||
onAddMaterial: _addMaterial.open,
|
||||
footEnd: _BibliographyButton(studyset: studyset),
|
||||
// The topics, the introduction and the bibliography, one at a time
|
||||
// (decided by the developer, 10 Nov 2026): in the side bar from
|
||||
// expanded windows on, below that in a sheet ([_showTopics]).
|
||||
final rail = StudysetSidePanel(
|
||||
studyset: studyset,
|
||||
topics: StudysetRail(
|
||||
parent: studyset,
|
||||
all: all,
|
||||
onAddMaterial: _addMaterial.open,
|
||||
),
|
||||
);
|
||||
final description = studyset.content.description.isBlank
|
||||
? null
|
||||
: _Description(studyset: studyset);
|
||||
final color =
|
||||
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
|
||||
Brand.primary;
|
||||
|
|
@ -289,7 +288,6 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
rail,
|
||||
_Content(studysetId: studyset.id, parts: parts),
|
||||
parts,
|
||||
description,
|
||||
color,
|
||||
),
|
||||
);
|
||||
|
|
@ -302,7 +300,6 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget rail,
|
||||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description,
|
||||
Color color,
|
||||
) {
|
||||
final studyset = widget.studyset;
|
||||
|
|
@ -362,7 +359,6 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
rail,
|
||||
content,
|
||||
parts,
|
||||
description,
|
||||
width: constraints.maxWidth,
|
||||
),
|
||||
),
|
||||
|
|
@ -390,8 +386,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget? fab,
|
||||
Widget rail,
|
||||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description, {
|
||||
_PartsView parts, {
|
||||
required double width,
|
||||
}) {
|
||||
final roomy = width >= _roomyWidth;
|
||||
|
|
@ -532,11 +527,6 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
),
|
||||
),
|
||||
CopyUpdateBanner(studyset: studyset),
|
||||
if (!wide && description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: SectionCard(child: description),
|
||||
),
|
||||
Expanded(
|
||||
child: wide
|
||||
? Padding(
|
||||
|
|
@ -551,21 +541,10 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
children: [
|
||||
SizedBox(
|
||||
width: _sideColumnWidth,
|
||||
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),
|
||||
scrolls: true,
|
||||
child: rail,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: SectionCard(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
scrolls: true,
|
||||
child: rail,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _gutter),
|
||||
|
|
@ -645,13 +624,15 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: StudysetRail(
|
||||
parent: studyset,
|
||||
all: all,
|
||||
onAddMaterial: () => choose(const _AddMaterialChoice()),
|
||||
onOpen: (topic) => choose(_OpenTopic(topic)),
|
||||
onStudy: (chosen) => choose(_StudyTopic(chosen)),
|
||||
footEnd: _BibliographyButton(studyset: studyset),
|
||||
child: StudysetSidePanel(
|
||||
studyset: studyset,
|
||||
topics: StudysetRail(
|
||||
parent: studyset,
|
||||
all: all,
|
||||
onAddMaterial: () => choose(const _AddMaterialChoice()),
|
||||
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
|
||||
/// share it: whether that is settled yet, the parts there are, the one
|
||||
/// 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
|
||||
/// flashcards" ([inAppBar]) or "Learn 12" (floating) while cards are due,
|
||||
/// "Study" otherwise; off, saying why, with nothing to study (an archived
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue