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:
parent
9edb23a379
commit
c19a8e1db2
17 changed files with 722 additions and 8 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('猫'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue