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:
parent
4c47ec06c1
commit
c5cbc53bdd
12 changed files with 382 additions and 0 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
108
halkyon_learn_flutter/lib/src/core/widget/more_info_sheet.dart
Normal file
108
halkyon_learn_flutter/lib/src/core/widget/more_info_sheet.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue