feat(app): More info about a card

"More info", in a card's menu while studying or in the list, shows when
it was made and last changed (its ids say), its id and its studyset's
(and its PDF's), each copied with a tap, and how often it was studied
and when last. The sheet is a shared one, for notes and paths to follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-07 21:43:38 +02:00
commit c5cbc53bdd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 382 additions and 0 deletions

View file

@ -628,6 +628,17 @@
"commonSave": "Speichern",
"commonClose": "Schließen",
"commonUndo": "Rückgängig",
"moreInfo": "Weitere Informationen",
"moreInfoCreated": "Erstellt",
"moreInfoModified": "Zuletzt geändert",
"moreInfoItemId": "Element-ID",
"moreInfoStudysetId": "Lernset-ID",
"moreInfoPdfId": "PDF-Quellen-ID",
"moreInfoReviews": "Wiederholungen",
"moreInfoReviewCount": "Anzahl Wiederholungen",
"moreInfoLastReviewed": "Zuletzt wiederholt",
"moreInfoCopy": "Kopieren",
"copiedToClipboard": "In die Zwischenablage kopiert",
"commonTryAgain": "Erneut versuchen",
"personDataFailed": "Die Daten dieses Geräts konnten nicht geöffnet werden.",
"sessionUnreachable": "Der Server war nicht erreichbar.",

View file

@ -708,6 +708,17 @@
"commonSave": "Save",
"commonClose": "Close",
"commonUndo": "Undo",
"moreInfo": "More info",
"moreInfoCreated": "Created",
"moreInfoModified": "Last modified",
"moreInfoItemId": "Item ID",
"moreInfoStudysetId": "Studyset ID",
"moreInfoPdfId": "PDF source ID",
"moreInfoReviews": "Reviews",
"moreInfoReviewCount": "Review count",
"moreInfoLastReviewed": "Last reviewed",
"moreInfoCopy": "Copy",
"copiedToClipboard": "Copied to clipboard",
"commonTryAgain": "Try again",
"personDataFailed": "Couldn't open this device's data.",
"sessionUnreachable": "Couldn't reach the server.",

View file

@ -3760,6 +3760,72 @@ abstract class AppLocalizations {
/// **'Undo'**
String get commonUndo;
/// No description provided for @moreInfo.
///
/// In en, this message translates to:
/// **'More info'**
String get moreInfo;
/// No description provided for @moreInfoCreated.
///
/// In en, this message translates to:
/// **'Created'**
String get moreInfoCreated;
/// No description provided for @moreInfoModified.
///
/// In en, this message translates to:
/// **'Last modified'**
String get moreInfoModified;
/// No description provided for @moreInfoItemId.
///
/// In en, this message translates to:
/// **'Item ID'**
String get moreInfoItemId;
/// No description provided for @moreInfoStudysetId.
///
/// In en, this message translates to:
/// **'Studyset ID'**
String get moreInfoStudysetId;
/// No description provided for @moreInfoPdfId.
///
/// In en, this message translates to:
/// **'PDF source ID'**
String get moreInfoPdfId;
/// No description provided for @moreInfoReviews.
///
/// In en, this message translates to:
/// **'Reviews'**
String get moreInfoReviews;
/// No description provided for @moreInfoReviewCount.
///
/// In en, this message translates to:
/// **'Review count'**
String get moreInfoReviewCount;
/// No description provided for @moreInfoLastReviewed.
///
/// In en, this message translates to:
/// **'Last reviewed'**
String get moreInfoLastReviewed;
/// No description provided for @moreInfoCopy.
///
/// In en, this message translates to:
/// **'Copy'**
String get moreInfoCopy;
/// No description provided for @copiedToClipboard.
///
/// In en, this message translates to:
/// **'Copied to clipboard'**
String get copiedToClipboard;
/// No description provided for @commonTryAgain.
///
/// In en, this message translates to:

View file

@ -2281,6 +2281,39 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get commonUndo => 'Rückgängig';
@override
String get moreInfo => 'Weitere Informationen';
@override
String get moreInfoCreated => 'Erstellt';
@override
String get moreInfoModified => 'Zuletzt geändert';
@override
String get moreInfoItemId => 'Element-ID';
@override
String get moreInfoStudysetId => 'Lernset-ID';
@override
String get moreInfoPdfId => 'PDF-Quellen-ID';
@override
String get moreInfoReviews => 'Wiederholungen';
@override
String get moreInfoReviewCount => 'Anzahl Wiederholungen';
@override
String get moreInfoLastReviewed => 'Zuletzt wiederholt';
@override
String get moreInfoCopy => 'Kopieren';
@override
String get copiedToClipboard => 'In die Zwischenablage kopiert';
@override
String get commonTryAgain => 'Erneut versuchen';

View file

@ -2265,6 +2265,39 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get commonUndo => 'Undo';
@override
String get moreInfo => 'More info';
@override
String get moreInfoCreated => 'Created';
@override
String get moreInfoModified => 'Last modified';
@override
String get moreInfoItemId => 'Item ID';
@override
String get moreInfoStudysetId => 'Studyset ID';
@override
String get moreInfoPdfId => 'PDF source ID';
@override
String get moreInfoReviews => 'Reviews';
@override
String get moreInfoReviewCount => 'Review count';
@override
String get moreInfoLastReviewed => 'Last reviewed';
@override
String get moreInfoCopy => 'Copy';
@override
String get copiedToClipboard => 'Copied to clipboard';
@override
String get commonTryAgain => 'Try again';

View file

@ -0,0 +1,108 @@
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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A fact about something: its name, and its value; a copyable one (an
/// id) is set in a monospace font and copied with a tap.
typedef InfoRow = ({String label, String value, bool copyable});
/// Facts that belong together, under a title if any.
typedef InfoSection = ({String? title, List<InfoRow> rows});
/// Shows [sections] of facts about something, each in a card of its own:
/// "More info".
Future<void> showMoreInfoSheet(
BuildContext context,
List<InfoSection> sections,
) => showAdaptiveSheet<void>(
context,
builder: (context) => SheetScaffold(
title: AppLocalizations.of(context).moreInfo,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (:title, :rows) in sections)
if (rows.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SectionCard(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall,
),
),
for (final row in rows) _Row(row),
],
),
),
),
],
),
),
);
class const _Row(final InfoRow row) extends StatelessWidget {
void _copy(BuildContext context) {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
Clipboard.setData(ClipboardData(text: row.value));
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.copiedToClipboard)));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final text = Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
row.label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
row.value,
style: row.copyable
? theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')
: theme.textTheme.bodyMedium,
),
],
),
);
if (!row.copyable) {
return text;
}
return InkWell(
onTap: () => _copy(context),
child: Row(
children: [
Expanded(child: text),
IconButton(
tooltip: AppLocalizations.of(context).moreInfoCopy,
icon: const Icon(LucideIcons.copy, size: 18),
onPressed: () => _copy(context),
),
],
),
);
}
}

View file

@ -0,0 +1,76 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.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/card/model/card.dart';
import 'package:intl/intl.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
/// Shows what there is to know about [card], in the studyset [studysetId]:
/// when it was made and last changed, its ids, and how often it was studied.
Future<void> showCardInfo(
BuildContext context,
Card card, {
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,
).reviews.reviewsOf({card.id}))
if (review.mode.rates) review.reviewedAt,
]..sort();
if (!context.mounted) {
return;
}
String when(DateTime at) => format.format(at.toLocal());
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);
await showMoreInfoSheet(context, [
(
title: null,
rows: [
if (created != null)
(label: l10n.moreInfoCreated, value: when(created), copyable: false),
if (modified != null)
(
label: l10n.moreInfoModified,
value: when(modified),
copyable: false,
),
],
),
(
title: null,
rows: [
(label: l10n.moreInfoItemId, value: card.id, copyable: true),
(label: l10n.moreInfoStudysetId, value: studysetId, copyable: true),
if (card.content.source case (:final pdfId, page: _))
(label: l10n.moreInfoPdfId, value: pdfId, copyable: true),
],
),
(
title: l10n.moreInfoReviews,
rows: [
(
label: l10n.moreInfoReviewCount,
value: NumberFormat.decimalPattern(
Localizations.localeOf(context).toLanguageTag(),
).format(ratings.length),
copyable: false,
),
if (ratings.lastOrNull case final last?)
(
label: l10n.moreInfoLastReviewed,
value: when(last),
copyable: false,
),
],
),
]);
}

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart'
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -315,6 +316,7 @@ class const _Cards(
}
},
onResolve: () => _report(context, card, null),
onInfo: () => showCardInfo(context, card, studysetId: studysetId),
onBuryToggle: () =>
_bury(context, card, buried: buried.contains(card.id)),
onOpen: () => context.push(RoutePaths.card(studysetId, card.id)),

View file

@ -34,6 +34,9 @@ class const CardTile({
/// Buries it until tomorrow, or brings it back while [buried].
required final VoidCallback onBuryToggle,
/// Shows what there is to know about it.
required final VoidCallback onInfo,
/// Whether it is buried for the day.
final bool buried = false,
super.key,
@ -170,6 +173,11 @@ class const CardTile({
style: TextStyle(color: theme.colorScheme.error),
),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info, size: 18),
onPressed: onInfo,
child: Text(l10n.moreInfo),
),
],
builder: (context, menu, _) => IconButton(
tooltip: l10n.cardTileOptions,

View file

@ -77,6 +77,7 @@ enum _CardAction() {
report,
bury,
delete,
info,
}
/// The row on top of a studied card: the PDF it was made from, its tags on
@ -102,6 +103,9 @@ class const StudyCardHeader({
/// Deletes the card, asking first.
required final VoidCallback onDelete,
/// Shows what there is to know about the card.
required final VoidCallback onInfo,
super.key,
}) extends StatelessWidget {
@override
@ -149,6 +153,7 @@ class const StudyCardHeader({
_CardAction.report => onReport(),
_CardAction.bury => onBury(),
_CardAction.delete => onDelete(),
_CardAction.info => onInfo(),
},
itemBuilder: (context) => [
item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd),
@ -161,6 +166,7 @@ class const StudyCardHeader({
l10n.cardTileDelete,
color: error,
),
item(_CardAction.info, LucideIcons.info, l10n.moreInfo),
],
),
],

View file

@ -7,6 +7,7 @@ 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/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_chip.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
@ -281,6 +282,7 @@ class const _Showing(
},
);
},
onInfo: () => showCardInfo(context, card, studysetId: state.studysetId),
onDelete: () async {
final bloc = context.read<StudySessionBloc>();
if (await confirmDestructiveAction(

View file

@ -179,4 +179,30 @@ void main() {
findsNothing,
);
});
testWidgets("a card's More info shows its ids, to copy, and its reviews", (
tester,
) async {
final id = await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
await tester.tap(
find.descendant(
of: find.byType(CardTile),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('More info'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(id), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
expect(find.text('Review count'), findsOneWidget);
await tester.tap(find.byTooltip('Copy').first);
await tester.pump();
expect(find.text('Copied to clipboard'), findsOneWidget);
});
}