feat(app): import flashcards from text

Import content gains Flashcards: pasted, or from a CSV or TSV file, one
card per line, split at its first tab, comma or semicolon (a .csv picks
commas), lines without both sides left out. Quoted fields are read as
spreadsheets write them, and a UTF-8 byte order mark is ignored. The
cards come in at the studyset's end with the origin "text", so the
dashboard doesn't count them as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 14:43:49 +02:00
commit 18c1370bf6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 728 additions and 1 deletions

View file

@ -242,6 +242,22 @@
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
"studysetImportAnki": "Anki-Deck",
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
"studysetImportCards": "Karteikarten",
"studysetImportCardsHint": "Eingefügt oder aus einer CSV- oder TSV-Datei",
"cardTextImportTitle": "Karteikarten importieren",
"cardTextImportDescription": "Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen.",
"cardTextImportHint": "Füge die Karten hier ein",
"cardTextImportLoadFile": "CSV- oder TSV-Datei laden",
"cardTextImportFileType": "CSV- oder TSV-Dateien",
"cardTextImportUnreadable": "Diese Datei ist kein Text in UTF-8.",
"cardTextImportSeparator": "Trennzeichen",
"cardTextSeparatorTab": "Tab",
"cardTextSeparatorComma": "Komma",
"cardTextSeparatorSemicolon": "Semikolon",
"cardTextImportNone": "Noch keine Karten erkannt.",
"cardTextImportReady": "{count, plural, =1{1 Karte bereit zum Importieren.} other{{count} Karten bereit zum Importieren.}}",
"cardTextImportSubmit": "Importieren",
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
"studysetReorder": "Umsortieren",
"studysetReorderPaths": "Pfade umsortieren",
"studysetReorderDone": "Fertig",

View file

@ -268,6 +268,24 @@
"studysetImportPathHint": "From a Halkyon path JSON file",
"studysetImportAnki": "Anki deck",
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
"studysetImportCards": "Flashcards",
"studysetImportCardsHint": "Pasted, or from a CSV or TSV file",
"cardTextImportTitle": "Import flashcards",
"cardTextImportDescription": "One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.",
"cardTextImportHint": "Paste the cards here",
"cardTextImportLoadFile": "Load a CSV or TSV file",
"cardTextImportFileType": "CSV or TSV files",
"cardTextImportUnreadable": "This file isn't text in UTF-8.",
"cardTextImportSeparator": "Separator",
"cardTextSeparatorTab": "Tab",
"cardTextSeparatorComma": "Comma",
"cardTextSeparatorSemicolon": "Semicolon",
"cardTextImportNone": "No cards found yet.",
"cardTextImportReady": "{count, plural, =1{1 card ready to import.} other{{count} cards ready to import.}}",
"@cardTextImportReady": {"placeholders": {"count": {"type": "int"}}},
"cardTextImportSubmit": "Import",
"cardTextImported": "{count, plural, =1{Imported 1 flashcard.} other{Imported {count} flashcards.}}",
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
"studysetReorder": "Reorder",
"studysetReorderPaths": "Reorder paths",
"studysetReorderDone": "Done",

View file

@ -1510,6 +1510,102 @@ abstract class AppLocalizations {
/// **'From an .apkg or .colpkg file'**
String get studysetImportAnkiHint;
/// No description provided for @studysetImportCards.
///
/// In en, this message translates to:
/// **'Flashcards'**
String get studysetImportCards;
/// No description provided for @studysetImportCardsHint.
///
/// In en, this message translates to:
/// **'Pasted, or from a CSV or TSV file'**
String get studysetImportCardsHint;
/// No description provided for @cardTextImportTitle.
///
/// In en, this message translates to:
/// **'Import flashcards'**
String get cardTextImportTitle;
/// No description provided for @cardTextImportDescription.
///
/// In en, this message translates to:
/// **'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.'**
String get cardTextImportDescription;
/// No description provided for @cardTextImportHint.
///
/// In en, this message translates to:
/// **'Paste the cards here'**
String get cardTextImportHint;
/// No description provided for @cardTextImportLoadFile.
///
/// In en, this message translates to:
/// **'Load a CSV or TSV file'**
String get cardTextImportLoadFile;
/// No description provided for @cardTextImportFileType.
///
/// In en, this message translates to:
/// **'CSV or TSV files'**
String get cardTextImportFileType;
/// No description provided for @cardTextImportUnreadable.
///
/// In en, this message translates to:
/// **'This file isn\'t text in UTF-8.'**
String get cardTextImportUnreadable;
/// No description provided for @cardTextImportSeparator.
///
/// In en, this message translates to:
/// **'Separator'**
String get cardTextImportSeparator;
/// No description provided for @cardTextSeparatorTab.
///
/// In en, this message translates to:
/// **'Tab'**
String get cardTextSeparatorTab;
/// No description provided for @cardTextSeparatorComma.
///
/// In en, this message translates to:
/// **'Comma'**
String get cardTextSeparatorComma;
/// No description provided for @cardTextSeparatorSemicolon.
///
/// In en, this message translates to:
/// **'Semicolon'**
String get cardTextSeparatorSemicolon;
/// No description provided for @cardTextImportNone.
///
/// In en, this message translates to:
/// **'No cards found yet.'**
String get cardTextImportNone;
/// No description provided for @cardTextImportReady.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 card ready to import.} other{{count} cards ready to import.}}'**
String cardTextImportReady(int count);
/// No description provided for @cardTextImportSubmit.
///
/// In en, this message translates to:
/// **'Import'**
String get cardTextImportSubmit;
/// No description provided for @cardTextImported.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Imported 1 flashcard.} other{Imported {count} flashcards.}}'**
String cardTextImported(int count);
/// No description provided for @studysetReorder.
///
/// In en, this message translates to:

View file

@ -890,6 +890,72 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetImportAnkiHint => 'Aus einer .apkg- oder .colpkg-Datei';
@override
String get studysetImportCards => 'Karteikarten';
@override
String get studysetImportCardsHint =>
'Eingefügt oder aus einer CSV- oder TSV-Datei';
@override
String get cardTextImportTitle => 'Karteikarten importieren';
@override
String get cardTextImportDescription =>
'Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen.';
@override
String get cardTextImportHint => 'Füge die Karten hier ein';
@override
String get cardTextImportLoadFile => 'CSV- oder TSV-Datei laden';
@override
String get cardTextImportFileType => 'CSV- oder TSV-Dateien';
@override
String get cardTextImportUnreadable => 'Diese Datei ist kein Text in UTF-8.';
@override
String get cardTextImportSeparator => 'Trennzeichen';
@override
String get cardTextSeparatorTab => 'Tab';
@override
String get cardTextSeparatorComma => 'Komma';
@override
String get cardTextSeparatorSemicolon => 'Semikolon';
@override
String get cardTextImportNone => 'Noch keine Karten erkannt.';
@override
String cardTextImportReady(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karten bereit zum Importieren.',
one: '1 Karte bereit zum Importieren.',
);
return '$_temp0';
}
@override
String get cardTextImportSubmit => 'Importieren';
@override
String cardTextImported(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten importiert.',
one: '1 Karteikarte importiert.',
);
return '$_temp0';
}
@override
String get studysetReorder => 'Umsortieren';

View file

@ -884,6 +884,71 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetImportAnkiHint => 'From an .apkg or .colpkg file';
@override
String get studysetImportCards => 'Flashcards';
@override
String get studysetImportCardsHint => 'Pasted, or from a CSV or TSV file';
@override
String get cardTextImportTitle => 'Import flashcards';
@override
String get cardTextImportDescription =>
'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.';
@override
String get cardTextImportHint => 'Paste the cards here';
@override
String get cardTextImportLoadFile => 'Load a CSV or TSV file';
@override
String get cardTextImportFileType => 'CSV or TSV files';
@override
String get cardTextImportUnreadable => 'This file isn\'t text in UTF-8.';
@override
String get cardTextImportSeparator => 'Separator';
@override
String get cardTextSeparatorTab => 'Tab';
@override
String get cardTextSeparatorComma => 'Comma';
@override
String get cardTextSeparatorSemicolon => 'Semicolon';
@override
String get cardTextImportNone => 'No cards found yet.';
@override
String cardTextImportReady(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count cards ready to import.',
one: '1 card ready to import.',
);
return '$_temp0';
}
@override
String get cardTextImportSubmit => 'Import';
@override
String cardTextImported(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Imported $count flashcards.',
one: 'Imported 1 flashcard.',
);
return '$_temp0';
}
@override
String get studysetReorder => 'Reorder';

View file

@ -0,0 +1,77 @@
import 'dart:convert';
/// What stands between a card's front and its back on a line of text.
enum CardSeparator(final String char) {
/// What Quizlet and Anki export, and what text rarely holds otherwise.
tab('\t'),
comma(','),
semicolon(';');
/// The separator a file named [fileName] most likely uses: commas in a
/// CSV file, tabs in anything else.
static CardSeparator forFile(String fileName) =>
fileName.toLowerCase().endsWith('.csv') ? comma : tab;
}
/// A card's front and back, read from a line of text.
typedef CardLine = ({String front, String back});
/// The cards in [text]: one per line, split at the line's first [separator],
/// so the back may hold the separator too. A field in double quotes, as
/// spreadsheets write one holding the separator, is read without them (a
/// doubled quote inside standing for one). Blank lines, lines without the
/// separator and lines with an empty side (a header without a back, say)
/// are left out.
List<CardLine> parseCardLines(String text, CardSeparator separator) => [
for (final line in const LineSplitter().convert(
// Spreadsheets put a byte order mark before UTF-8 text.
text.startsWith('') ? text.substring(1) : text,
))
if (_split(line, separator.char) case final card?
when card.front.isNotEmpty && card.back.isNotEmpty)
card,
];
CardLine? _split(String line, String separator) {
final start = line.length - line.trimLeft().length;
if (!line.startsWith('"', start)) {
final at = line.indexOf(separator);
return at < 0
? null
: (
front: line.substring(0, at).trim(),
back: _unquoted(line.substring(at + separator.length).trim()),
);
}
// A quoted front: up to its closing quote, then the separator.
final front = StringBuffer();
var i = start + 1;
while (true) {
if (i >= line.length) {
return null;
}
if (line[i] != '"') {
front.write(line[i++]);
} else if (line.startsWith('""', i)) {
front.write('"');
i += 2;
} else {
i++;
break;
}
}
final at = line.indexOf(separator, i);
if (at < 0 || line.substring(i, at).trim().isNotEmpty) {
return null;
}
return (
front: front.toString().trim(),
back: _unquoted(line.substring(at + separator.length).trim()),
);
}
/// [field] without the double quotes around it, if it has them.
String _unquoted(String field) =>
field.length >= 2 && field.startsWith('"') && field.endsWith('"')
? field.substring(1, field.length - 1).replaceAll('""', '"')
: field;

View file

@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:convert';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_text_import.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for flashcards as text, pasted or read from a CSV or TSV file, one
/// per line, and adds them to the end of the studyset [studysetId] in their
/// order, as front/back cards imported from text. Completes with how many
/// came in, or null when the sheet closed without importing.
Future<int?> importCardsFromText(
BuildContext context, {
required String studysetId,
}) async {
final cards = PersonScope.of(context).cards;
final lines = await showAdaptiveSheet<List<CardLine>>(
context,
builder: (context) => const _CardTextSheet(),
);
if (lines == null || lines.isEmpty) {
return null;
}
await cards.createAll(studysetId, [
for (final line in lines)
CardContent(
front: RichContent.plain(line.front),
back: RichContent.plain(line.back),
),
], origin: 'text');
return lines.length;
}
class const _CardTextSheet() extends StatefulWidget {
@override
State<_CardTextSheet> createState() => _CardTextSheetState();
}
class _CardTextSheetState() extends State<_CardTextSheet> {
final _text = TextEditingController();
var _separator = CardSeparator.tab;
var _unreadable = false;
@override
void dispose() {
_text.dispose();
super.dispose();
}
/// Reads a CSV or TSV file into the field, its separator chosen by its
/// name (the person may still change it).
Future<void> _load() async {
final l10n = AppLocalizations.of(context);
final file = await DependenciesScope.of(context).files.pick(
label: l10n.cardTextImportFileType,
extensions: const ['csv', 'tsv', 'txt'],
);
if (file == null || !mounted) {
return;
}
try {
final text = utf8.decode(file.bytes);
setState(() {
_text.text = text;
_separator = CardSeparator.forFile(file.name);
_unreadable = false;
});
} on FormatException {
setState(() => _unreadable = true);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final lines = parseCardLines(_text.text, _separator);
return SheetScaffold(
title: l10n.cardTextImportTitle,
footer: FilledButton(
onPressed: lines.isEmpty
? null
: () => Navigator.of(context).pop(lines),
child: Text(l10n.cardTextImportSubmit),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.cardTextImportDescription,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Align(
alignment: AlignmentDirectional.centerStart,
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.fileUp),
label: Text(l10n.cardTextImportLoadFile),
onPressed: () => unawaited(_load()),
),
),
if (_unreadable) ...[
const SizedBox(height: 8),
Text(
l10n.cardTextImportUnreadable,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: 12),
TextField(
controller: _text,
minLines: 6,
maxLines: 12,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
hintText: l10n.cardTextImportHint,
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
Text(l10n.cardTextImportSeparator, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
SegmentedButton<CardSeparator>(
segments: [
for (final each in CardSeparator.values)
ButtonSegment(
value: each,
label: Text(switch (each) {
CardSeparator.tab => l10n.cardTextSeparatorTab,
CardSeparator.comma => l10n.cardTextSeparatorComma,
CardSeparator.semicolon => l10n.cardTextSeparatorSemicolon,
}),
),
],
selected: {_separator},
showSelectedIcon: false,
onSelectionChanged: (chosen) =>
setState(() => _separator = chosen.single),
),
const SizedBox(height: 12),
Text(
lines.isEmpty
? l10n.cardTextImportNone
: l10n.cardTextImportReady(lines.length),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}

View file

@ -22,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
@ -1161,7 +1162,8 @@ class const _AddMaterial({
}
/// Asks what to import into the studyset [studysetId], and imports it: a
/// learning path from a file in the paths' import format, or an Anki deck.
/// learning path from a file in the paths' import format, an Anki deck, or
/// flashcards as text.
Future<void> _importContent(BuildContext context, String studysetId) async {
final l10n = AppLocalizations.of(context);
final chosen = await showAdaptiveSheet<_Import>(
@ -1186,6 +1188,13 @@ Future<void> _importContent(BuildContext context, String studysetId) async {
subtitle: Text(l10n.studysetImportAnkiHint),
onTap: () => Navigator.of(sheetContext).pop(_Import.anki),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileSpreadsheet),
title: Text(l10n.studysetImportCards),
subtitle: Text(l10n.studysetImportCardsHint),
onTap: () => Navigator.of(sheetContext).pop(_Import.cards),
),
],
),
),
@ -1206,6 +1215,16 @@ Future<void> _importContent(BuildContext context, String studysetId) async {
case AnkiImportMode.subStudysets || null:
break;
}
case _Import.cards:
final tabs = context.read<ContentTabCubit>();
final messenger = ScaffoldMessenger.of(context);
final count = await importCardsFromText(context, studysetId: studysetId);
if (count != null) {
tabs.show(StudysetTab.flashcards);
messenger.showSnackBar(
SnackBar(content: Text(l10n.cardTextImported(count))),
);
}
case null:
break;
}
@ -1215,6 +1234,7 @@ Future<void> _importContent(BuildContext context, String studysetId) async {
enum _Import() {
path,
anki,
cards,
}
/// Picks a file in the paths' import format and hands it to the paths'

View file

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_text_import.dart';
void main() {
test('one card per line, split at its first separator, so a back may '
'hold one too', () {
expect(parseCardLines('hola\thello\nadiós\tgood\tbye', CardSeparator.tab), [
(front: 'hola', back: 'hello'),
(front: 'adiós', back: 'good\tbye'),
]);
});
test('leaves out blank lines, lines without a separator and lines with '
'an empty side, such as a header without a back', () {
expect(
parseCardLines(
'Spanish vocab\n\nhola;hello\n;nothing in front\nnothing behind;\n',
CardSeparator.semicolon,
),
[(front: 'hola', back: 'hello')],
);
});
test('trims both sides, and reads Windows line endings', () {
expect(
parseCardLines(' hola , hello \r\nsí,yes\r\n', CardSeparator.comma),
[
(front: 'hola', back: 'hello'),
(front: 'sí', back: 'yes'),
],
);
});
test('reads quoted fields as spreadsheets write them: separators inside, '
'a doubled quote for one', () {
expect(
parseCardLines(
'"hello, ""friend""",hola amigo\nthank you,"gracias, señor"',
CardSeparator.comma,
),
[
(front: 'hello, "friend"', back: 'hola amigo'),
(front: 'thank you', back: 'gracias, señor'),
],
);
});
test('leaves out a line whose quoted front never closes', () {
expect(parseCardLines('"hello,hola', CardSeparator.comma), isEmpty);
});
test('ignores the byte order mark spreadsheets put before UTF-8 text', () {
expect(parseCardLines('hola,hello', CardSeparator.comma), [
(front: 'hola', back: 'hello'),
]);
});
test('a file says its separator by its name: commas for .csv, tabs for '
'the rest', () {
expect(CardSeparator.forFile('words.CSV'), CardSeparator.comma);
expect(CardSeparator.forFile('words.tsv'), CardSeparator.tab);
expect(CardSeparator.forFile('words.txt'), CardSeparator.tab);
});
}

View file

@ -0,0 +1,141 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeFilePicker files;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'spanish',
encodeStudysetPayload(StudysetContent.titled('Spanish vocab')),
);
cardGateway = FakeCardGateway();
files = FakeFilePicker();
});
Future<void> showApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
files: files,
),
location: RoutePaths.studyset('spanish'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// Opens the studyset's Import content and chooses Flashcards.
Future<void> openImport(WidgetTester tester) async {
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Flashcards').last);
await tester.pumpAndSettle();
}
Future<List<(String, String)>> stored() async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
return [
for (final card in await cards.cardsOf('spanish'))
(
card.content.front.plainText.trim(),
card.content.back.plainText.trim(),
),
];
}
testWidgets('pasted lines become front/back cards at the end of the '
'studyset, in order, marked as imported; the Flashcards tab shows '
'them', (tester) async {
await showApp(tester);
await openImport(tester);
expect(find.text('No cards found yet.'), findsOneWidget);
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Import'))
.onPressed,
isNull,
);
await tester.enterText(
find.byType(TextField),
'hola\thello\nadiós\tgoodbye\nno separator here',
);
await tester.pump();
expect(find.text('2 cards ready to import.'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pumpAndSettle();
expect(find.text('Imported 2 flashcards.'), findsOneWidget);
expect(await stored(), [('hola', 'hello'), ('adiós', 'goodbye')]);
expect(
[
for (final revision in await cardGateway.allRevisions())
cardOriginOf(revision.payload),
],
['text', 'text'],
);
expect(find.text('hola'), findsOneWidget);
});
testWidgets('a CSV file fills the field and switches to commas', (
tester,
) async {
await showApp(tester);
await openImport(tester);
files.next = (
name: 'words.csv',
bytes: Uint8List.fromList(utf8.encode('"hello, friend",hola amigo\n')),
);
await tester.tap(find.text('Load a CSV or TSV file'));
await tester.pumpAndSettle();
expect(files.asked.single, ['csv', 'tsv', 'txt']);
expect(find.text('1 card ready to import.'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pumpAndSettle();
expect(await stored(), [('hello, friend', 'hola amigo')]);
});
testWidgets("a file that isn't UTF-8 text says so", (tester) async {
await showApp(tester);
await openImport(tester);
files.next = (name: 'words.csv', bytes: Uint8List.fromList([0xff, 0xfe]));
await tester.tap(find.text('Load a CSV or TSV file'));
await tester.pumpAndSettle();
expect(find.text("This file isn't text in UTF-8."), findsOneWidget);
});
}