feat(app): the card composer, for many cards in one sitting
Create flashcards, and tapping a card, open the composer: the stack of cards on the left (New flashcard on top, the new ones, then the stored ones; collapsible), the card being written on the right, saved as the person leaves it (another card, the pane, the screen, the app). The title says how saving stands; leaving with cards that couldn't be saved asks first. A card has its tags and their pin, an action menu (duplicate, clear, delete), the type switch (flashcard, vocabulary, matching), the front, and below a line its answer side: one answer or several to mark right or wrong, the reading and meanings, or categories and items. Fields changed differently on two devices show every version to keep one. The single-card editor goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a5515c13f7
commit
0afc22d454
2 changed files with 2701 additions and 735 deletions
|
|
@ -0,0 +1,222 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/card/model/card_content.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' hide Card;
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
/// Rich content with one paragraph of [text].
|
||||
RichContent paragraph(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
CardContent card(String front, String back) =>
|
||||
CardContent(front: paragraph(front), back: paragraph(back));
|
||||
|
||||
void main() {
|
||||
late FakeCardGateway gateway;
|
||||
late CardRepository cards;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeCardGateway();
|
||||
cards = CardRepository(gateway);
|
||||
});
|
||||
tearDown(() => cards.dispose());
|
||||
|
||||
/// Lets the editors' timers run out and the screen settle.
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the studyset `japanese`, with the cards the test gave [cards].
|
||||
Future<void> showStudyset(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
),
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(studysetGateway: studysets, cardGateway: gateway),
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Opens the card with [front] in the composer.
|
||||
Future<void> open(WidgetTester tester, String front) async {
|
||||
await tester.tap(find.text(front));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Chooses [item] of the card's ⋮ menu.
|
||||
Future<void> action(WidgetTester tester, String item) async {
|
||||
await tester.tap(find.byTooltip('More actions'));
|
||||
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 settle(tester);
|
||||
}
|
||||
|
||||
testWidgets('Create flashcards opens the composer on a new card of a '
|
||||
'studyset without any; leaving it untouched asks nothing', (
|
||||
tester,
|
||||
) async {
|
||||
await showStudyset(tester);
|
||||
|
||||
await tester.tap(find.text('Add material'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Create flashcards'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Create flashcards for "Japanese"'), findsOneWidget);
|
||||
expect(find.text('Front'), findsOneWidget);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
expect(gateway.written, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a card opens in the composer among the others; an edit is '
|
||||
'saved on leaving it', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '犬');
|
||||
|
||||
// Both cards are in the stack; the one tapped is open.
|
||||
expect(find.text('dog'), findsWidgets);
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'いぬ');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'dog');
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).last.content;
|
||||
expect(saved.type, CardType.vocabulary);
|
||||
expect(saved.reading, 'いぬ');
|
||||
expect(saved.meanings, ['dog']);
|
||||
expect(saved.back, RichContent.empty());
|
||||
expect(find.text('All changes saved'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a second answer makes a multiple-choice card, one answer '
|
||||
'right', (tester) async {
|
||||
await cards.create('japanese', card('Capital of Japan?', 'Tokyo'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, 'Capital of Japan?');
|
||||
|
||||
await tester.tap(find.text('Add answer'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Answers'), findsOneWidget);
|
||||
expect(find.text('Correct answer'), findsOneWidget);
|
||||
expect(find.text('Wrong answer'), findsOneWidget);
|
||||
// The only right answer can't turn wrong.
|
||||
expect(tester.widget<Switch>(find.byType(Switch).first).onChanged, isNull);
|
||||
});
|
||||
|
||||
testWidgets('leaving with a card that is incomplete asks first', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'ねこ');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('Discard unsaved flashcard?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await settle(tester);
|
||||
expect(find.text('Discard unsaved flashcard?'), findsNothing);
|
||||
expect(gateway.written, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('a card changed differently on two devices shows every '
|
||||
'version; keeping one saves it on leaving, which settles it', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final created = (await cards.cardsOf('japanese')).single;
|
||||
await cards.save(created, card('猫', 'a cat'));
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'rev-9000',
|
||||
cardId: created.id,
|
||||
parents: created.heads,
|
||||
payload: encodeCardPayload(card('猫', 'the cat')),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
expect(
|
||||
find.textContaining('changed differently on two devices'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Keep this'), findsNWidgets(2));
|
||||
await tester.tap(find.text('Keep this').first);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await settle(tester);
|
||||
|
||||
final resolved = (await cards.cardsOf('japanese')).single;
|
||||
expect(resolved.hasConflict, isFalse);
|
||||
expect(resolved.content.back, paragraph('a cat'));
|
||||
expect(find.text('Create flashcards for "Japanese"'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('deleting a stored card asks first, then blanks and removes '
|
||||
'it', (tester) async {
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await showStudyset(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await action(tester, 'Delete');
|
||||
expect(find.text('Delete this flashcard?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await settle(tester);
|
||||
expect(gateway.deletions, isEmpty);
|
||||
|
||||
await action(tester, 'Delete');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await settle(tester);
|
||||
|
||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||
});
|
||||
}
|
||||
|
|
@ -68,13 +68,6 @@ void main() {
|
|||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
Future<void> open(WidgetTester tester, String front) async {
|
||||
await tester.tap(find.text(front));
|
||||
await tester.pump();
|
||||
// Flushes the editor's timers.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
/// A card whose back was changed differently on this device and another.
|
||||
Future<void> conflictedCard() async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
|
|
@ -114,88 +107,4 @@ void main() {
|
|||
|
||||
expect(find.byTooltip('Changed on two devices'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a new card can be saved only with content', (tester) async {
|
||||
await showList(tester);
|
||||
|
||||
await tester.tap(find.text('Add material'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Create flashcards'));
|
||||
// A menu item acts once the menu has closed, a frame later.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
// Flushes the editor's timers.
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('New card'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, Icons.save_outlined),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a conflicted side shows every version; keeping one lets the '
|
||||
'card be saved, which resolves it', (tester) async {
|
||||
await conflictedCard();
|
||||
await showList(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
expect(
|
||||
find.textContaining('changed differently on two devices'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The versions show in the editor's read-only view.
|
||||
expect(find.textContaining('a cat', findRichText: true), findsOneWidget);
|
||||
expect(find.textContaining('the cat', findRichText: true), findsOneWidget);
|
||||
IconButton save() => tester.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, Icons.save_outlined),
|
||||
);
|
||||
expect(save().onPressed, isNull);
|
||||
|
||||
await tester.tap(find.text('Keep this').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(save().onPressed, isNotNull);
|
||||
|
||||
await tester.tap(find.byTooltip('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final resolved = (await cards.cardsOf('japanese')).single;
|
||||
expect(resolved.hasConflict, isFalse);
|
||||
expect(resolved.content.back, paragraph('a cat'));
|
||||
expect(find.text('Card'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('deleting asks first; Cancel keeps the card', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.byTooltip('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete this card?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.deletions, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a confirmed delete blanks and removes the card and closes '
|
||||
'it', (tester) async {
|
||||
final id = await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
await open(tester, '猫');
|
||||
|
||||
await tester.tap(find.byTooltip('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue