feat(app): More info for studysets, paths, notes and PDFs

Wherever the old menus offered it: the studyset's options, a path's
(in the list and in the runner), a note's, and a PDF's, all on the
shared sheet the cards use: when it was made (from its UUID v7 id) and
last changed where that is kept, its ids to copy (parent, studyset,
PDF), and for a PDF its file.

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-08 14:38:55 +02:00
commit a35318a5a6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 332 additions and 40 deletions

View file

@ -681,6 +681,8 @@
"moreInfoItemId": "Element-ID",
"moreInfoStudysetId": "Lernset-ID",
"moreInfoPdfId": "PDF-Quellen-ID",
"moreInfoParentId": "Übergeordnete ID",
"moreInfoFile": "Datei",
"moreInfoReviews": "Wiederholungen",
"moreInfoReviewCount": "Anzahl Wiederholungen",
"moreInfoLastReviewed": "Zuletzt wiederholt",

View file

@ -764,6 +764,8 @@
"moreInfoItemId": "Item ID",
"moreInfoStudysetId": "Studyset ID",
"moreInfoPdfId": "PDF source ID",
"moreInfoParentId": "Parent ID",
"moreInfoFile": "File",
"moreInfoReviews": "Reviews",
"moreInfoReviewCount": "Review count",
"moreInfoLastReviewed": "Last reviewed",

View file

@ -4066,6 +4066,18 @@ abstract class AppLocalizations {
/// **'PDF source ID'**
String get moreInfoPdfId;
/// No description provided for @moreInfoParentId.
///
/// In en, this message translates to:
/// **'Parent ID'**
String get moreInfoParentId;
/// No description provided for @moreInfoFile.
///
/// In en, this message translates to:
/// **'File'**
String get moreInfoFile;
/// No description provided for @moreInfoReviews.
///
/// In en, this message translates to:

View file

@ -2467,6 +2467,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get moreInfoPdfId => 'PDF-Quellen-ID';
@override
String get moreInfoParentId => 'Übergeordnete ID';
@override
String get moreInfoFile => 'Datei';
@override
String get moreInfoReviews => 'Wiederholungen';

View file

@ -2448,6 +2448,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get moreInfoPdfId => 'PDF source ID';
@override
String get moreInfoParentId => 'Parent ID';
@override
String get moreInfoFile => 'File';
@override
String get moreInfoReviews => 'Reviews';

View file

@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -12,6 +13,11 @@ typedef InfoRow = ({String label, String value, bool copyable});
/// Facts that belong together, under a title if any.
typedef InfoSection = ({String? title, List<InfoRow> rows});
/// [at] as a fact shows a moment: the date and the time of day, local.
String infoTime(BuildContext context, DateTime at) => DateFormat.yMMMd(
Localizations.localeOf(context).toLanguageTag(),
).add_jm().format(at.toLocal());
/// Shows [sections] of facts about something, each in a card of its own:
/// "More info".
Future<void> showMoreInfoSheet(

View file

@ -15,9 +15,6 @@ Future<void> showCardInfo(
required String studysetId,
}) async {
final l10n = AppLocalizations.of(context);
final format = DateFormat.yMMMd(
Localizations.localeOf(context).toLanguageTag(),
).add_jm();
final ratings = [
for (final review in await PersonScope.of(
context,
@ -27,7 +24,7 @@ Future<void> showCardInfo(
if (!context.mounted) {
return;
}
String when(DateTime at) => format.format(at.toLocal());
String when(DateTime at) => infoTime(context, at);
final created = uuidV7Time(card.id);
// Revision ids sort by when they were written: the last is the latest.
final modified = uuidV7Time((card.heads.toList()..sort()).last);

View file

@ -0,0 +1,36 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart';
import 'package:material_ui/material_ui.dart';
/// Shows what there is to know about the learning path [pathId] of the
/// studyset [studysetId]: when it was made, and its ids. A path's content
/// keeps no time of its last change.
Future<void> showPathInfo(
BuildContext context, {
required String pathId,
required String studysetId,
}) {
final l10n = AppLocalizations.of(context);
final created = uuidV7Time(pathId);
return showMoreInfoSheet(context, [
(
title: null,
rows: [
if (created != null)
(
label: l10n.moreInfoCreated,
value: infoTime(context, created),
copyable: false,
),
],
),
(
title: null,
rows: [
(label: l10n.moreInfoItemId, value: pathId, copyable: true),
(label: l10n.moreInfoStudysetId, value: studysetId, copyable: true),
],
),
]);
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
@ -10,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -131,6 +134,13 @@ class const _PathTile({
onPressed: () => _delete(context),
child: Text(l10n.pathDelete, style: TextStyle(color: error)),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info),
onPressed: () => unawaited(
showPathInfo(context, pathId: path.id, studysetId: studysetId),
),
child: Text(l10n.moreInfo),
),
],
builder: (context, menu, _) => GestureDetector(
onLongPressStart: (details) =>

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -51,7 +52,7 @@ class const PathRunnerScreen({
RoutePaths.pathStep(studysetId, pathId, step.id),
),
),
if (state.loaded) _PathMenu(pathId: pathId),
if (state.loaded) _PathMenu(studysetId: studysetId, pathId: pathId),
],
),
body: switch (state) {
@ -73,8 +74,12 @@ class const PathRunnerScreen({
}
}
/// The path's options, behind the … button: start it afresh, or delete it.
class const _PathMenu({required final String pathId}) extends StatelessWidget {
/// The path's options, behind the … button: start it afresh, delete it, or
/// see what there is to know about it.
class const _PathMenu({
required final String studysetId,
required final String pathId,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -91,6 +96,13 @@ class const _PathMenu({required final String pathId}) extends StatelessWidget {
onPressed: () => unawaited(_delete(context)),
child: Text(l10n.pathDelete, style: TextStyle(color: error)),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info),
onPressed: () => unawaited(
showPathInfo(context, pathId: pathId, studysetId: studysetId),
),
child: Text(l10n.moreInfo),
),
],
builder: (context, menu, _) => IconButton(
icon: const Icon(LucideIcons.ellipsis),

View file

@ -0,0 +1,44 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:material_ui/material_ui.dart';
/// Shows what there is to know about [note], in the studyset [studysetId]:
/// when it was made and last changed, and its ids.
Future<void> showNoteInfo(
BuildContext context,
Note note, {
required String studysetId,
}) {
final l10n = AppLocalizations.of(context);
final created = uuidV7Time(note.id);
return showMoreInfoSheet(context, [
(
title: null,
rows: [
if (created != null)
(
label: l10n.moreInfoCreated,
value: infoTime(context, created),
copyable: false,
),
if (note.editedAt case final modified?)
(
label: l10n.moreInfoModified,
value: infoTime(context, modified),
copyable: false,
),
],
),
(
title: null,
rows: [
(label: l10n.moreInfoItemId, value: note.id, copyable: true),
(label: l10n.moreInfoStudysetId, value: studysetId, copyable: true),
if (note.content.source case (:final pdfId, page: _))
(label: l10n.moreInfoPdfId, value: pdfId, copyable: true),
],
),
]);
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -10,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/edited_ago.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_info.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -67,7 +70,8 @@ class const NoteList({required final String studysetId, super.key})
}
/// One note: its title ("Untitled note" without one) and when it was last
/// edited; its options behind the … button, or a long press.
/// edited; its options (edit, delete, more info) behind the … button, or a
/// long press.
class const _NoteTile({
required final String studysetId,
required final Note note,
@ -96,6 +100,12 @@ class const _NoteTile({
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info),
onPressed: () =>
unawaited(showNoteInfo(context, note, studysetId: studysetId)),
child: Text(l10n.moreInfo),
),
],
builder: (context, menu, _) => ListTile(
title: Text(

View file

@ -7,11 +7,12 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
@ -303,37 +304,60 @@ class const _PdfTile({
}
}
Future<void> _moreInfo(BuildContext context) => showAdaptiveSheet<void>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final content = row.source.content;
return SheetScaffold(
title: content.title,
body: Column(
children: [
ListTile(
title: Text(l10n.pdfInfoFileName),
subtitle: Text(content.fileName),
/// What there is to know about the PDF: when it was added, its ids, and
/// its file.
Future<void> _moreInfo(BuildContext context) {
final l10n = AppLocalizations.of(context);
final source = row.source;
final created = uuidV7Time(source.id);
return showMoreInfoSheet(context, [
(
title: null,
rows: [
if (created != null)
(
label: l10n.moreInfoCreated,
value: infoTime(context, created),
copyable: false,
),
ListTile(
title: Text(l10n.pdfInfoSize),
subtitle: Text(switch (row.byteSize) {
final size? => formatFileSize(l10n, size),
null => l10n.pdfInfoUnknown,
}),
),
ListTile(
title: Text(l10n.pdfInfoPages),
subtitle: Text(
content.pageCount?.toString() ?? l10n.pdfInfoUnknown,
),
),
],
),
);
},
);
],
),
(
title: null,
rows: [
(label: l10n.moreInfoItemId, value: source.id, copyable: true),
(
label: l10n.moreInfoStudysetId,
value: source.studysetId,
copyable: true,
),
],
),
(
title: l10n.moreInfoFile,
rows: [
(
label: l10n.pdfInfoFileName,
value: source.content.fileName,
copyable: false,
),
(
label: l10n.pdfInfoSize,
value: switch (row.byteSize) {
final size? => formatFileSize(l10n, size),
null => l10n.pdfInfoUnknown,
},
copyable: false,
),
(
label: l10n.pdfInfoPages,
value: source.content.pageCount?.toString() ?? l10n.pdfInfoUnknown,
copyable: false,
),
],
),
]);
}
/// Asks, then hands the deletion to the person's PDF deletions and offers
/// to undo it for a moment; the deletion outlives this tile.

View file

@ -0,0 +1,39 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:material_ui/material_ui.dart';
/// Shows what there is to know about [studyset]: when it was made and last
/// changed, its id and its parent's.
Future<void> showStudysetInfo(BuildContext context, Studyset studyset) {
final l10n = AppLocalizations.of(context);
final created = uuidV7Time(studyset.id);
return showMoreInfoSheet(context, [
(
title: null,
rows: [
if (created != null)
(
label: l10n.moreInfoCreated,
value: infoTime(context, created),
copyable: false,
),
if (studyset.updatedAt case final modified?)
(
label: l10n.moreInfoModified,
value: infoTime(context, modified),
copyable: false,
),
],
),
(
title: null,
rows: [
(label: l10n.moreInfoItemId, value: studyset.id, copyable: true),
if (studyset.parentId case final parentId?)
(label: l10n.moreInfoParentId, value: parentId, copyable: true),
],
),
]);
}

View file

@ -40,6 +40,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyse
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -1349,10 +1350,12 @@ enum _Option() {
archive,
unarchive,
delete,
info,
}
/// The studyset's options: edit, move, its study options, import content,
/// reset its progress, archive or unarchive, delete.
/// reset its progress, archive or unarchive, delete, and what there is to
/// know about it.
class const _Options({
required final Studyset studyset,
required final List<Studyset> all,
@ -1439,6 +1442,7 @@ class const _Options({
studyset,
onDeleted: () => _leave(context),
),
_Option.info => showStudysetInfo(context, studyset),
},
itemBuilder: (context) => [
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
@ -1468,6 +1472,7 @@ class const _Options({
l10n.studysetMenuDelete,
color: Theme.of(context).colorScheme.error,
),
item(_Option.info, LucideIcons.info, l10n.moreInfo),
],
);
}

View file

@ -409,6 +409,32 @@ void main() {
expect(pathGateway.deletions, isEmpty);
});
testWidgets("a path's More info, from its tile and from its runner, shows "
'its ids', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'More info');
expect(find.text(id), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await tester.tap(find.text('Particles'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'More info');
expect(find.text(id), findsOneWidget);
});
testWidgets("a step's sources open from below its text, cited as the "
'device cites, in the order of a reference list', (tester) async {
final paths = repository();

View file

@ -152,6 +152,25 @@ void main() {
);
});
testWidgets("a note's More info shows its ids, to copy", (
tester,
) async {
final created = await stored('Particles', 'は');
await showApp(tester, RoutePaths.studyset('japanese'));
await openNotesTab(tester);
await tester.tap(find.byTooltip('Note options'));
await tester.pumpAndSettle();
await tester.tap(find.text('More info'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item ID'), findsOneWidget);
expect(find.text(created.noteId), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
});
testWidgets('deleting asks, takes the note from the list, and Undo brings '
'it back', (tester) async {
await stored('Particles', 'は');

View file

@ -219,6 +219,26 @@ void main() {
expect(pdfGateway.deletions, isEmpty);
});
testWidgets("a PDF's More info shows its ids and its file, as other "
"things' More info does", (tester) async {
await repository().import(
'japanese',
bytes: pdf('kana'),
fileName: 'kana.pdf',
);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'More info');
expect(find.text('Item ID'), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
expect(find.text('File'), findsOneWidget);
expect(find.text('kana.pdf'), findsOneWidget);
expect(find.text('Pages'), findsOneWidget);
});
testWidgets('a deadline may be to finish a chapter, read from the PDF', (
tester,
) async {

View file

@ -364,6 +364,22 @@ void main() {
expect(find.text('Learning path'), findsOneWidget);
});
testWidgets("a studyset's More info shows its id and its parent's, to "
'copy', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await showApp(tester, location: RoutePaths.studyset('kana'));
await tester.pumpAndSettle();
await choose(tester, 'More info');
expect(find.text('Item ID'), findsOneWidget);
expect(find.text('kana'), findsOneWidget);
expect(find.text('Parent ID'), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
expect(find.byTooltip('Copy'), findsNWidgets(2));
});
testWidgets('the bibliography is a button above the content, not in the '
'app bar; on a phone at the foot of the topics', (tester) async {
stored('japanese', 'Japanese');