feat(app): report what is wrong with a card, and bury cards from the list

- "Report an error", from a card's menu while studying or in the list:
  a category (typo, grammar, content, other) and a note. The card is
  marked to be fixed, and still studied; the list shows the category
  (the note on hover), and "Resolve report" takes it back. A report is
  a revision of the card, so it follows it to every device.
- The list buries a card until tomorrow and brings it back ("Unbury"),
  showing "Buried" meanwhile.
- Each of these says so with an Undo.

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:31:48 +02:00
commit c19a8e1db2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 722 additions and 8 deletions

View file

@ -356,6 +356,22 @@
"cardNextHint": "Nächster Hinweis",
"cardReport": "Meldung",
"cardSenses": "Wortbedeutungen",
"cardReportTitle": "Fehler melden",
"cardReportSubtitle": "Markiere diese Karte, um sie später zu finden und zu korrigieren.",
"cardReportTypo": "Tippfehler",
"cardReportGrammar": "Grammatik",
"cardReportContent": "Inhalt",
"cardReportOther": "Sonstiges",
"cardReportNote": "Notiz",
"cardReportNoteHint": "Was muss korrigiert werden?",
"cardReportSubmit": "Melden",
"cardReported": "Zur Korrektur markiert",
"cardResolveReport": "Meldung erledigen",
"cardReportResolved": "Meldung erledigt",
"cardBuriedBadge": "Zurückgestellt",
"cardUnbury": "Zurückstellung aufheben",
"cardTileBuried": "Karteikarte bis morgen zurückgestellt",
"cardTileUnburied": "Karteikarte nicht mehr zurückgestellt",
"cardFilterTooltip": "Filter",
"cardFilterTitle": "Filter",
"cardFilterTag": "Tag",

View file

@ -397,6 +397,22 @@
"cardNextHint": "Next hint",
"cardReport": "Report",
"cardSenses": "Senses",
"cardReportTitle": "Report an error",
"cardReportSubtitle": "Flag this card so you can find and fix it later.",
"cardReportTypo": "Typo",
"cardReportGrammar": "Grammar",
"cardReportContent": "Content",
"cardReportOther": "Other",
"cardReportNote": "Note",
"cardReportNoteHint": "What needs fixing?",
"cardReportSubmit": "Report",
"cardReported": "Flagged for fixing",
"cardResolveReport": "Resolve report",
"cardReportResolved": "Report resolved",
"cardBuriedBadge": "Buried",
"cardUnbury": "Unbury",
"cardTileBuried": "Flashcard buried until tomorrow",
"cardTileUnburied": "Flashcard unburied",
"cardFilterTooltip": "Filter",
"cardFilterTitle": "Filter",
"cardFilterTag": "Tag",

View file

@ -2170,6 +2170,102 @@ abstract class AppLocalizations {
/// **'Senses'**
String get cardSenses;
/// No description provided for @cardReportTitle.
///
/// In en, this message translates to:
/// **'Report an error'**
String get cardReportTitle;
/// No description provided for @cardReportSubtitle.
///
/// In en, this message translates to:
/// **'Flag this card so you can find and fix it later.'**
String get cardReportSubtitle;
/// No description provided for @cardReportTypo.
///
/// In en, this message translates to:
/// **'Typo'**
String get cardReportTypo;
/// No description provided for @cardReportGrammar.
///
/// In en, this message translates to:
/// **'Grammar'**
String get cardReportGrammar;
/// No description provided for @cardReportContent.
///
/// In en, this message translates to:
/// **'Content'**
String get cardReportContent;
/// No description provided for @cardReportOther.
///
/// In en, this message translates to:
/// **'Other'**
String get cardReportOther;
/// No description provided for @cardReportNote.
///
/// In en, this message translates to:
/// **'Note'**
String get cardReportNote;
/// No description provided for @cardReportNoteHint.
///
/// In en, this message translates to:
/// **'What needs fixing?'**
String get cardReportNoteHint;
/// No description provided for @cardReportSubmit.
///
/// In en, this message translates to:
/// **'Report'**
String get cardReportSubmit;
/// No description provided for @cardReported.
///
/// In en, this message translates to:
/// **'Flagged for fixing'**
String get cardReported;
/// No description provided for @cardResolveReport.
///
/// In en, this message translates to:
/// **'Resolve report'**
String get cardResolveReport;
/// No description provided for @cardReportResolved.
///
/// In en, this message translates to:
/// **'Report resolved'**
String get cardReportResolved;
/// No description provided for @cardBuriedBadge.
///
/// In en, this message translates to:
/// **'Buried'**
String get cardBuriedBadge;
/// No description provided for @cardUnbury.
///
/// In en, this message translates to:
/// **'Unbury'**
String get cardUnbury;
/// No description provided for @cardTileBuried.
///
/// In en, this message translates to:
/// **'Flashcard buried until tomorrow'**
String get cardTileBuried;
/// No description provided for @cardTileUnburied.
///
/// In en, this message translates to:
/// **'Flashcard unburied'**
String get cardTileUnburied;
/// No description provided for @cardFilterTooltip.
///
/// In en, this message translates to:

View file

@ -1271,6 +1271,55 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cardSenses => 'Wortbedeutungen';
@override
String get cardReportTitle => 'Fehler melden';
@override
String get cardReportSubtitle =>
'Markiere diese Karte, um sie später zu finden und zu korrigieren.';
@override
String get cardReportTypo => 'Tippfehler';
@override
String get cardReportGrammar => 'Grammatik';
@override
String get cardReportContent => 'Inhalt';
@override
String get cardReportOther => 'Sonstiges';
@override
String get cardReportNote => 'Notiz';
@override
String get cardReportNoteHint => 'Was muss korrigiert werden?';
@override
String get cardReportSubmit => 'Melden';
@override
String get cardReported => 'Zur Korrektur markiert';
@override
String get cardResolveReport => 'Meldung erledigen';
@override
String get cardReportResolved => 'Meldung erledigt';
@override
String get cardBuriedBadge => 'Zurückgestellt';
@override
String get cardUnbury => 'Zurückstellung aufheben';
@override
String get cardTileBuried => 'Karteikarte bis morgen zurückgestellt';
@override
String get cardTileUnburied => 'Karteikarte nicht mehr zurückgestellt';
@override
String get cardFilterTooltip => 'Filter';

View file

@ -1262,6 +1262,55 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cardSenses => 'Senses';
@override
String get cardReportTitle => 'Report an error';
@override
String get cardReportSubtitle =>
'Flag this card so you can find and fix it later.';
@override
String get cardReportTypo => 'Typo';
@override
String get cardReportGrammar => 'Grammar';
@override
String get cardReportContent => 'Content';
@override
String get cardReportOther => 'Other';
@override
String get cardReportNote => 'Note';
@override
String get cardReportNoteHint => 'What needs fixing?';
@override
String get cardReportSubmit => 'Report';
@override
String get cardReported => 'Flagged for fixing';
@override
String get cardResolveReport => 'Resolve report';
@override
String get cardReportResolved => 'Report resolved';
@override
String get cardBuriedBadge => 'Buried';
@override
String get cardUnbury => 'Unbury';
@override
String get cardTileBuried => 'Flashcard buried until tomorrow';
@override
String get cardTileUnburied => 'Flashcard unburied';
@override
String get cardFilterTooltip => 'Filter';

View file

@ -1,17 +1,22 @@
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_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/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// Shows a studyset's cards in order with their tags, all of them or those
/// with one tag, and moves them; the list follows every change, local or
/// synced.
/// Shows a studyset's cards in order with their tags and which are buried
/// for the day, all of them or those with one tag; moves, reports and buries
/// them. The list follows every change, local or synced.
class CardListBloc(
final CardRepository _cards,
final TagRepository _tags,
final ReviewRepository _reviews,
final String _studysetId,
) extends Bloc<CardListEvent, CardListState> {
this : super(const CardListStateLoading()) {
@ -20,6 +25,29 @@ class CardListBloc(
on<CardListEventDeleteRequested>(
(event, emit) => _cards.delete(event.card),
);
on<CardListEventReported>(
(event, emit) => _cards.saveEdit(
event.card.id,
event.card.content.withField(CardField.report, event.report),
basedOn: event.card.heads,
),
);
on<CardListEventBuryToggled>((event, emit) async {
final now = clock.now();
if (_buried.contains(event.card.id)) {
await _reviews.unburyToday(event.card.id, startOfLocalDay(now));
} else {
await _reviews.bury(event.card.id, now);
}
});
on<CardListEventBuriedRead>((event, emit) async {
_buried = await _reviews.buriedSince({
for (final card in _cardsNow ?? const <Card>[]) card.id,
}, startOfLocalDay(clock.now()));
if (_loaded() case final loaded?) {
emit(loaded);
}
});
on<CardListEventTagFilterChanged>((event, emit) {
_tagFilter = event.tagId;
if (_loaded() case final loaded?) {
@ -30,6 +58,7 @@ class CardListBloc(
List<Card>? _cardsNow;
var _tagging = Tagging.empty;
var _buried = <String>{};
String? _tagFilter;
Future<void> _onStarted(
@ -41,10 +70,15 @@ class CardListBloc(
_cards.watch(_studysetId),
onData: (cards) {
_cardsNow = cards;
add(const CardListEventBuriedRead());
return _loaded()!;
},
onError: (_, _) => const CardListStateFailed(),
),
emit.onEach(
_reviews.changes,
onData: (_) => add(const CardListEventBuriedRead()),
),
emit.forEach(
_tags.watch(),
onData: (tagging) {
@ -68,6 +102,7 @@ class CardListBloc(
cards,
tagging: _tagging,
tagFilter: _tagFilter,
buried: _buried,
),
null => null,
};
@ -112,6 +147,26 @@ final class const CardListEventTagFilterChanged(
List<Object?> get props => [tagId];
}
/// Mark [card] as having something wrong with [report]; null resolves its
/// report.
final class const CardListEventReported(
final Card card,
final CardReport? report,
) extends CardListEvent {
@override
List<Object?> get props => [card, report];
}
/// Bury [card] until tomorrow, or bring it back when it is.
final class const CardListEventBuryToggled(final Card card)
extends CardListEvent {
@override
List<Object?> get props => [card];
}
/// Read which cards are buried, after a change; the bloc sends it itself.
final class const CardListEventBuriedRead() extends CardListEvent;
/// A studyset's cards, as the list shows them.
sealed class const CardListState() with Equatable {
@override
@ -134,6 +189,9 @@ final class const CardListStateLoaded(
/// The id of the tag the list is narrowed to; null for all cards.
final String? tagFilter,
/// The ids of the cards buried for the day.
final Set<String> buried = const {},
}) extends CardListState {
/// The tag the list is narrowed to, while it exists.
Tag? get filter => switch (tagFilter) {
@ -167,5 +225,5 @@ final class const CardListStateLoaded(
}
@override
List<Object?> get props => [cards, tagging, tagFilter];
List<Object?> get props => [cards, tagging, tagFilter, buried];
}

View file

@ -4,10 +4,14 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.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/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_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';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
@ -26,6 +30,9 @@ class const CardList({
/// The person's tags.
required final TagRepository tags,
/// The person's reviews, which say what is buried.
required final ReviewRepository reviews,
/// The studyset whose cards these are.
required final String studysetId,
super.key,
@ -33,7 +40,7 @@ class const CardList({
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
CardListBloc(cards, tags, studysetId)
CardListBloc(cards, tags, reviews, studysetId)
..add(const CardListEventStarted()),
child: BlocBuilder<CardListBloc, CardListState>(
builder: (context, state) => switch (state) {
@ -54,6 +61,7 @@ class const CardList({
studysetId,
loaded.shown,
loaded.tagging,
buried: loaded.buried,
reorderable: loaded.filter == null,
),
],
@ -233,10 +241,58 @@ class const _Cards(
final List<Card> cards,
final Tagging tagging, {
/// The ids of the cards buried for the day.
required final Set<String> buried,
/// Whether cards can be dragged to another place: not while only some
/// show, as the places between them are unclear.
required final bool reorderable,
}) extends StatelessWidget {
/// Marks [card] with [report] (null resolving it), saying so with a way
/// back to how it was.
void _report(BuildContext context, Card card, CardReport? report) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<CardListBloc>()
..add(CardListEventReported(card, report));
final before = card.content.report;
showUndoSnackBar(
context,
message: report == null ? l10n.cardReportResolved : l10n.cardReported,
onUndo: () async {
// The card as reported, for the undo to build on.
final now = await bloc.stream
.map(
(state) => switch (state) {
CardListStateLoaded(:final cards) =>
cards.where((each) => each.id == card.id).firstOrNull,
_ => null,
},
)
.firstWhere((each) => each?.content.report == report);
if (now != null && !bloc.isClosed) {
bloc.add(CardListEventReported(now, before));
}
},
);
}
/// Buries [card] until tomorrow, or brings it back, saying so with a way
/// back.
void _bury(BuildContext context, Card card, {required bool buried}) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<CardListBloc>()
..add(CardListEventBuryToggled(card));
showUndoSnackBar(
context,
message: buried ? l10n.cardTileUnburied : l10n.cardTileBuried,
onUndo: () {
if (!bloc.isClosed) {
bloc.add(CardListEventBuryToggled(card));
}
},
);
}
@override
Widget build(BuildContext context) => ReorderableListView(
shrinkWrap: true,
@ -251,6 +307,16 @@ class const _Cards(
key: ValueKey(card.id),
card: card,
tags: tagging.of(card.id),
buried: buried.contains(card.id),
onReport: () async {
if (await showReportSheet(context) case final report?
when context.mounted) {
_report(context, card, report);
}
},
onResolve: () => _report(context, card, null),
onBuryToggle: () =>
_bury(context, card, buried: buried.contains(card.id)),
onOpen: () => context.push(RoutePaths.card(studysetId, card.id)),
onDelete: () async {
final bloc = context.read<CardListBloc>();

View file

@ -1,6 +1,7 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -23,6 +24,18 @@ class const CardTile({
/// Deletes it; asks first.
required final VoidCallback onDelete,
/// Asks what is wrong with it, to mark it.
required final VoidCallback onReport,
/// Takes back its report.
required final VoidCallback onResolve,
/// Buries it until tomorrow, or brings it back while [buried].
required final VoidCallback onBuryToggle,
/// Whether it is buried for the day.
final bool buried = false,
super.key,
}) extends StatelessWidget {
@override
@ -30,7 +43,9 @@ class const CardTile({
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final front = previewOf(card.content.front, l10n);
final report = card.content.report;
final hasMeta = tags.isNotEmpty;
final hasBadges = report != null || buried;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Material(
@ -61,6 +76,33 @@ class const CardTile({
const _ConflictBadge(),
const SizedBox(height: 6),
],
if (hasBadges) ...[
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (report != null)
Tooltip(
message: report.note,
child: _Badge(
icon: LucideIcons.flag,
label: reportCategoryLabel(
l10n,
report.category,
),
color: theme.colorScheme.error,
),
),
if (buried)
_Badge(
icon: LucideIcons.moon,
label: l10n.cardBuriedBadge,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
const SizedBox(height: 6),
],
if (hasMeta) ...[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
@ -91,6 +133,31 @@ class const CardTile({
onPressed: onOpen,
child: Text(l10n.cardTileEdit),
),
if (report == null)
MenuItemButton(
leadingIcon: const Icon(LucideIcons.flag, size: 18),
onPressed: onReport,
child: Text(l10n.cardReportTitle),
)
else
MenuItemButton(
leadingIcon: const Icon(
LucideIcons.flagOff,
size: 18,
),
onPressed: onResolve,
child: Text(l10n.cardResolveReport),
),
MenuItemButton(
leadingIcon: Icon(
buried ? LucideIcons.sunrise : LucideIcons.moon,
size: 18,
),
onPressed: onBuryToggle,
child: Text(
buried ? l10n.cardUnbury : l10n.studyBuryCard,
),
),
MenuItemButton(
leadingIcon: Icon(
LucideIcons.trash2,
@ -122,6 +189,30 @@ class const CardTile({
}
}
/// A small mark above a card's front: an icon and a word in [color].
class const _Badge({
required final IconData icon,
required final String label,
required final Color color,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
],
);
}
/// Says a card was changed differently on two devices, to be settled in the
/// composer.
class const _ConflictBadge() extends StatelessWidget {

View file

@ -0,0 +1,101 @@
import 'package:clock/clock.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/feature/card/model/card_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Asks what is wrong with a card: a category and a note, starting from its
/// [current] report, if any. Completes with the report, made now, or with
/// null when the person closes the sheet.
Future<CardReport?> showReportSheet(
BuildContext context, {
CardReport? current,
}) => showAdaptiveSheet<CardReport>(
context,
builder: (context) => _ReportSheet(current: current),
);
/// [category]'s name as the person sees it.
String reportCategoryLabel(AppLocalizations l10n, ReportCategory category) =>
switch (category) {
ReportCategory.typo => l10n.cardReportTypo,
ReportCategory.grammar => l10n.cardReportGrammar,
ReportCategory.content => l10n.cardReportContent,
ReportCategory.other => l10n.cardReportOther,
};
class const _ReportSheet({required final CardReport? current})
extends StatefulWidget {
@override
State<_ReportSheet> createState() => _ReportSheetState();
}
class _ReportSheetState() extends State<_ReportSheet> {
late var _category = widget.current?.category ?? ReportCategory.typo;
late final _note = TextEditingController(text: widget.current?.note);
@override
void dispose() {
_note.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.cardReportTitle,
footer: FilledButton.icon(
icon: const Icon(LucideIcons.flag, size: 18),
label: Text(l10n.cardReportSubmit),
onPressed: () => Navigator.of(context).pop(
CardReport(
category: _category,
note: _note.text.trim(),
at: clock.now().toUtc(),
),
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Text(
l10n.cardReportSubtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final category in ReportCategory.values)
ChoiceChip(
label: Text(reportCategoryLabel(l10n, category)),
selected: category == _category,
onSelected: (_) => setState(() => _category = category),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _note,
minLines: 2,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: l10n.cardReportNote,
hintText: l10n.cardReportNoteHint,
),
),
],
),
);
}
}

View file

@ -65,6 +65,7 @@ class StudySessionBloc(
on<StudySessionEventBuried>(_onBuried);
on<StudySessionEventUnburied>(_onUnburied);
on<StudySessionEventCardEdited>(_onCardEdited);
on<StudySessionEventReported>(_onReported);
on<StudySessionEventCardDeleted>(_onCardDeleted);
on<StudySessionEventBackFirstToggled>((event, emit) async {
// A session focused on a side turns for itself; the settings stay as
@ -533,6 +534,21 @@ class StudySessionBloc(
}
}
Future<void> _onReported(
StudySessionEventReported event,
Emitter<StudySessionState> emit,
) async {
if (state case final StudySessionStateShowing showing) {
final card = showing.card;
await _cards.saveEdit(
card.id,
card.content.withField(CardField.report, event.report),
basedOn: card.heads,
);
await _onCardEdited(const StudySessionEventCardEdited(), emit);
}
}
Future<void> _onCardDeleted(
StudySessionEventCardDeleted event,
Emitter<StudySessionState> emit,
@ -731,6 +747,16 @@ final class const StudySessionEventTagsChanged(
/// The card on screen was edited elsewhere: show it as saved.
final class const StudySessionEventCardEdited() extends StudySessionEvent;
/// Mark the card on screen as having something wrong, with [report]; it
/// stays to be studied.
final class const StudySessionEventReported(
/// What is wrong.
final CardReport report,
) extends StudySessionEvent {
@override
List<Object?> get props => [report];
}
/// Delete the card on screen, and go on with the next.
final class const StudySessionEventCardDeleted() extends StudySessionEvent;

View file

@ -144,6 +144,22 @@ class ReviewRepository(final ReviewGateway _gateway) {
_changes.add(null);
}
/// Takes back every burial of [cardId] in the local day that began at
/// [startOfToday].
Future<void> unburyToday(String cardId, DateTime startOfToday) async {
for (final review in await reviewsOf({cardId})) {
if (review.mode == StudyMode.bury &&
!review.reviewedAt.isBefore(startOfToday)) {
await _gateway.deleteReviews(
{cardId},
review.reviewedAt,
StudyMode.bury.value,
);
}
}
_changes.add(null);
}
/// Of [cardIds], those set aside in the local day that began at
/// [startOfToday].
Future<Set<String>> buriedSince(

View file

@ -73,6 +73,7 @@ class const StudyCardSurface({
enum _CardAction() {
tags,
edit,
report,
bury,
delete,
}
@ -92,6 +93,9 @@ class const StudyCardHeader({
/// Opens the card to edit.
required final VoidCallback onEdit,
/// Asks what is wrong with the card, to mark it.
required final VoidCallback onReport,
/// Sets the card aside until tomorrow.
required final VoidCallback onBury,
@ -141,12 +145,14 @@ class const StudyCardHeader({
onSelected: (action) => switch (action) {
_CardAction.tags => onTags(),
_CardAction.edit => onEdit(),
_CardAction.report => onReport(),
_CardAction.bury => onBury(),
_CardAction.delete => onDelete(),
},
itemBuilder: (context) => [
item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd),
item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit),
item(_CardAction.report, LucideIcons.flag, l10n.cardReportTitle),
item(_CardAction.bury, LucideIcons.moon, l10n.studyBuryCard),
item(
_CardAction.delete,

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/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';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
@ -256,6 +257,17 @@ class const _Showing(
bloc.add(const StudySessionEventCardEdited());
}
},
onReport: () async {
final bloc = context.read<StudySessionBloc>();
final messenger = ScaffoldMessenger.of(context);
if (await showReportSheet(context, current: card.content.report)
case final report?) {
bloc.add(StudySessionEventReported(report));
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.cardReported)));
}
},
onBury: () {
final bloc = context.read<StudySessionBloc>()
..add(const StudySessionEventBuried());

View file

@ -495,6 +495,7 @@ class _ContentState()
CardList(
cards: PersonScope.of(context).cards,
tags: PersonScope.of(context).tags,
reviews: PersonScope.of(context).reviews,
studysetId: widget.studysetId,
),
],

View file

@ -3,9 +3,11 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
RichContent text(String text) => RichContent({
@ -19,21 +21,56 @@ void main() {
late FakeCardGateway gateway;
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
});
tearDown(() async {
await reviews.dispose();
await tags.dispose();
await cards.dispose();
});
group('CardListBloc', () {
test(
'a card reported or buried shows so, and either is taken back',
() async {
final id = await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, reviews, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
Future<CardListStateLoaded> next(
bool Function(CardListStateLoaded) test,
) async => await bloc.stream.firstWhere(
(state) => state is CardListStateLoaded && test(state),
) as CardListStateLoaded;
final neko = (await next((_) => true)).cards.single;
bloc.add(CardListEventBuryToggled(neko));
await next((state) => state.buried.contains(id));
bloc.add(CardListEventBuryToggled(neko));
await next((state) => state.buried.isEmpty);
final report = CardReport(
category: ReportCategory.typo,
at: DateTime.utc(2026, 10, 7),
);
bloc.add(CardListEventReported(neko, report));
final reported = await next(
(state) => state.cards.single.content.report == report,
);
bloc.add(CardListEventReported(reported.cards.single, null));
await next((state) => state.cards.single.content.report == null);
},
);
test('lists the cards and follows every change', () async {
await cards.create('japanese', card('猫', 'cat'));
final bloc = CardListBloc(cards, tags, 'japanese')
final bloc = CardListBloc(cards, tags, reviews, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
@ -63,7 +100,7 @@ void main() {
test('moves a card', () async {
await cards.create('japanese', card('a', ''));
await cards.create('japanese', card('b', ''));
final bloc = CardListBloc(cards, tags, 'japanese')
final bloc = CardListBloc(cards, tags, reviews, 'japanese')
..add(const CardListEventStarted());
addTearDown(bloc.close);
final loaded = await bloc.stream.firstWhere(
@ -100,7 +137,7 @@ void main() {
await tags.setTagsOf(ids[0], {noun});
await tags.setTagsOf(ids[1], {noun});
await tags.setTagsOf(ids[2], {verb});
bloc = CardListBloc(cards, tags, 'japanese')
bloc = CardListBloc(cards, tags, reviews, 'japanese')
..add(const CardListEventStarted());
});
tearDown(() => bloc.close());

View file

@ -132,4 +132,51 @@ void main() {
expect(gateway.deletions, ['blank $id', 'delete $id']);
expect(find.text('No flashcards yet'), findsOneWidget);
});
testWidgets("a card's options report it and bury it, each shown on the "
'card and taken back the same way', (tester) async {
await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
Future<void> choose(String item) async {
await tester.tap(
find.descendant(
of: find.byType(CardTile),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(item));
// A menu item acts once the menu has closed, a frame later.
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
}
await choose('Report an error');
await tester.tap(find.text('Grammar'));
await tester.tap(find.widgetWithText(FilledButton, 'Report'));
await tester.pumpAndSettle();
expect(find.text('Flagged for fixing'), findsOneWidget);
expect(
find.descendant(
of: find.byType(CardTile),
matching: find.text('Grammar'),
),
findsOneWidget,
);
await choose('Bury until tomorrow');
expect(find.text('Buried'), findsOneWidget);
await choose('Unbury');
expect(find.text('Buried'), findsNothing);
await choose('Resolve report');
expect(
find.descendant(
of: find.byType(CardTile),
matching: find.text('Grammar'),
),
findsNothing,
);
});
}

View file

@ -234,6 +234,33 @@ void main() {
},
);
test('reported, keeps the report and stays to be studied', () async {
await cards.create('japanese', card('猫'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
final report = CardReport(
category: ReportCategory.content,
note: 'Wrong animal',
at: now,
);
bloc.add(StudySessionEventReported(report));
await until(
bloc,
(s) =>
s is StudySessionStateShowing && s.card.content.report == report,
);
});
expect(
(await cards.cardsOf('japanese')).single.content.report?.note,
'Wrong animal',
);
});
test('edited elsewhere, shows as it was saved', () async {
final id = await cards.create('japanese', card('猫'));