perf(learn): flashcards moved in the card browser go at once, in one write

Moving the chosen cards to another studyset wrote them one at a time,
each looking up its privacy keys and writing its row, while the
browser still showed them. CardBrowserBloc now shows the move at once
(gone from here, or after the cards of the studyset they go into where
the browser shows it, _withMoves) and puts them back where the move
fails, counting the failure for the screen's snack bar
(moveFailures, cardBrowserMoveFailed).

CardRepository.moveTo hands the gateway every card's new position at
once (CardGateway.updateStudysets): SyncCardGateway reads them in one
query, checks every card's key before changing any (one private card
among them refuses the whole move, as before for each), rekeys those
that need it, and updates every row in one write and one sync request.
updateStudyset delegates to it.

Whats-New: Flashcards moved to another studyset in the card browser move at once.
Whats-New-De: Im Karteikarten-Browser in ein anderes Lernset verschobene Karteikarten sind sofort dort.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-07 23:19:20 +01:00
commit d6b2bec113
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 481 additions and 194 deletions

View file

@ -1482,6 +1482,7 @@
"cardBrowserDeleted": "{count, plural, =1{1 Karteikarte gelöscht} other{{count} Karteikarten gelöscht}}",
"cardBrowserMoveTitle": "{count, plural, =1{1 Karteikarte verschieben nach} other{{count} Karteikarten verschieben nach}}",
"cardBrowserMoved": "{count, plural, =1{1 Karteikarte nach „{title}“ verschoben} other{{count} Karteikarten nach „{title}“ verschoben}}",
"cardBrowserMoveFailed": "Die Karteikarten ließen sich nicht verschieben und sind wieder, wo sie waren.",
"cardBrowserAddTagsTitle": "{count, plural, =1{Tags zu 1 Karteikarte hinzufügen} other{Tags zu {count} Karteikarten hinzufügen}}",
"cardBrowserAddTo": "{count, plural, =1{Zu 1 Karteikarte hinzufügen} other{Zu {count} Karteikarten hinzufügen}}",
"cardBrowserRemoveTagsTitle": "{count, plural, =1{Tags von 1 Karteikarte entfernen} other{Tags von {count} Karteikarten entfernen}}",

View file

@ -1759,6 +1759,8 @@
"@cardBrowserMoveTitle": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserMoved": "{count, plural, =1{Moved 1 flashcard to “{title}”} other{Moved {count} flashcards to “{title}”}}",
"@cardBrowserMoved": {"placeholders": {"count": {"type": "int"}, "title": {"type": "String"}}},
"cardBrowserMoveFailed": "The flashcards couldn't be moved, so they are where they were.",
"@cardBrowserMoveFailed": {"description": "A snack bar when moving flashcards, shown moved at once, failed and they show where they were again."},
"cardBrowserAddTagsTitle": "{count, plural, =1{Add tags to 1 flashcard} other{Add tags to {count} flashcards}}",
"@cardBrowserAddTagsTitle": {"placeholders": {"count": {"type": "int"}}},
"cardBrowserAddTo": "{count, plural, =1{Add to 1 flashcard} other{Add to {count} flashcards}}",

View file

@ -8763,6 +8763,12 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{Moved 1 flashcard to “{title}”} other{Moved {count} flashcards to “{title}”}}'**
String cardBrowserMoved(int count, String title);
/// A snack bar when moving flashcards, shown moved at once, failed and they show where they were again.
///
/// In en, this message translates to:
/// **'The flashcards couldn\'t be moved, so they are where they were.'**
String get cardBrowserMoveFailed;
/// No description provided for @cardBrowserAddTagsTitle.
///
/// In en, this message translates to:

View file

@ -5502,6 +5502,10 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0';
}
@override
String get cardBrowserMoveFailed =>
'Die Karteikarten ließen sich nicht verschieben und sind wieder, wo sie waren.';
@override
String cardBrowserAddTagsTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(

View file

@ -5453,6 +5453,10 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0';
}
@override
String get cardBrowserMoveFailed =>
'The flashcards couldn\'t be moved, so they are where they were.';
@override
String cardBrowserAddTagsTitle(int count) {
String _temp0 = intl.Intl.pluralLogic(

View file

@ -20,7 +20,9 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// schedules, those a search, filters and a sort pick; a selection of them,
/// and what can be done to it all at once (move, tag, untag, start afresh;
/// deleting goes through [CardDeletions], whose Undo the screen offers).
/// Follows every change, local or synced.
/// Follows every change, local or synced. Cards moved show where they go at
/// once, gone from here where that is elsewhere (optimistic state), and
/// where they were again should the move fail ([CardBrowserState.moveFailures]).
class CardBrowserBloc(
final CardRepository _cards,
final TagRepository _tags,
@ -80,8 +82,30 @@ class CardBrowserBloc(
);
on<CardBrowserEventMoved>((event, emit) async {
final ids = _selectedInOrder();
emit(state.copyWith(selected: const {}, anchor: null));
await _cards.moveTo(event.studysetId, ids);
for (final id in ids) {
_moving[id] = event.studysetId;
}
emit(
_shown(
state.copyWith(
cards: _withMoves(state.cards),
selected: const {},
anchor: null,
),
),
);
try {
await _cards.moveTo(event.studysetId, ids);
} on Object catch (error) {
log('Moving cards failed', name: 'Browser', error: error);
emit(state.copyWith(moveFailures: state.moveFailures + 1));
} finally {
ids.forEach(_moving.remove);
if (!isClosed) {
// Read anew: where they went, or where they were.
add(const CardBrowserEventRefreshed());
}
}
});
on<CardBrowserEventTagsAdded>((event, emit) async {
await _tags.addTags({
@ -101,6 +125,31 @@ class CardBrowserBloc(
var _schedules = <String, CardSchedule>{};
var _pending = <String>{};
/// The cards being moved, by id, with the studyset each goes into.
final _moving = <String, String>{};
/// [cards] with those being moved where they go: after the cards of the
/// studyset they go into where the browser shows it, else left out.
List<BrowsedCard> _withMoves(List<BrowsedCard> cards) {
if (_moving.isEmpty) {
return cards;
}
BrowsedCard moved(BrowsedCard each) => (
card: each.card,
studysetId: _moving[each.card.id] ?? each.studysetId,
tags: each.tags,
schedule: each.schedule,
);
return [
for (final id in _studysetIds) ...[
for (final each in cards)
if (each.studysetId == id && !_moving.containsKey(each.card.id)) each,
for (final each in cards)
if (_moving[each.card.id] == id) moved(each),
],
];
}
/// What a search looks through in each card, kept while the card and
/// its tags are the same: thousands of cards, a search at every letter.
/// A card's content is the same while its heads are (ADR 0003), though a
@ -206,7 +255,7 @@ class CardBrowserBloc(
state.copyWith(
loaded: true,
failed: false,
cards: cards,
cards: _withMoves(cards),
// A card gone (deleted, moved elsewhere) is no longer chosen.
selected: state.selected.intersection(here),
),
@ -361,6 +410,10 @@ final class const CardBrowserState({
/// The card chosen last on its own, from which a range is chosen.
final String? anchor,
/// How many moves failed, the cards shown where they were again: the
/// screen says so for each.
final int moveFailures = 0,
}) with Equatable {
/// Whether a search or a filter narrows the cards.
bool get isFiltered =>
@ -381,6 +434,7 @@ final class const CardBrowserState({
bool? selecting,
Set<String>? selected,
Object? anchor = _keep,
int? moveFailures,
}) => CardBrowserState(
loaded: loaded ?? this.loaded,
failed: failed ?? this.failed,
@ -391,6 +445,7 @@ final class const CardBrowserState({
selecting: selecting ?? this.selecting,
selected: selected ?? this.selected,
anchor: identical(anchor, _keep) ? this.anchor : anchor as String?,
moveFailures: moveFailures ?? this.moveFailures,
);
@override
@ -404,6 +459,7 @@ final class const CardBrowserState({
selecting,
selected,
anchor,
moveFailures,
];
}

View file

@ -56,6 +56,14 @@ abstract interface class CardGateway() {
/// only column that changes.
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt);
/// Puts the cards [positions] names into the studyset [studysetId], each
/// at its position, written together; refuses all of them, writing none,
/// where one may not go there.
Future<void> updateStudysets(
String studysetId,
Map<String, String> positions,
);
/// Puts the card [cardId] into the studyset [studysetId], at [position].
Future<void> updateStudyset(
String cardId,

View file

@ -366,13 +366,16 @@ class CardRepository(final CardGateway _gateway) {
}
/// Moves the cards [cardIds] into [studysetId], at its end in their
/// order (as the browser lists them).
/// order (as the browser lists them), written together: a thousand cards
/// moved are one write, not a thousand.
Future<void> moveTo(String studysetId, List<String> cardIds) async {
var after = await _lastPosition(studysetId);
final positions = <String, String>{};
for (final id in cardIds) {
after = FractionalIndexing.generateKeyBetween(after, null);
await _gateway.updateStudyset(id, studysetId, after);
positions[id] = after;
}
await _gateway.updateStudysets(studysetId, positions);
_changed();
}

View file

@ -230,36 +230,64 @@ class SyncCardGateway(
String cardId,
String studysetId,
String position,
) => updateStudysets(studysetId, {cardId: position});
@override
Future<void> updateStudysets(
String studysetId,
Map<String, String> positions,
) async {
final card = await protocol.Card.db.findById(
final cards = await protocol.Card.db.find(
_db,
protocol.UuidValue.withValidation(cardId),
where: (t) => t.id.inSet(<protocol.UuidValue>{
for (final id in positions.keys) protocol.UuidValue.withValidation(id),
}),
);
if (card == null) {
if (cards.isEmpty) {
return;
}
// Its revisions and files take the key of the tree it moves into
// before it moves, so a move cut short leaves nothing readable; never
// Their revisions and files take the key of the tree they move into
// before they move, so a move cut short leaves nothing readable; never
// out of a private tree into a readable one, which the person is asked
// to make private first (the reading of 22 Oct).
final from = await _privacy.keyOfCard(cardId);
// to make private first (the reading of 22 Oct). Every card is checked
// before any is changed.
final into = await _privacy.keyToWriteIn(studysetId);
if (from != null && into == null) {
final from = {
for (final card in cards)
card.id!.uuid: await _privacy.keyOfCard(card.id!.uuid),
};
if (from.values.any((key) => key != null) && into == null) {
throw const PrivacyRefused(PrivacyRefusal.movedIntoReadable);
}
if (into != null && into != from) {
if (!_privacy.keys.canOpen(into)) {
if (into != null) {
final rekeyed = {
for (final MapEntry(key: id, value: key) in from.entries)
if (key != into) id: key,
};
if (rekeyed.isNotEmpty && !_privacy.keys.canOpen(into)) {
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
}
await _privacy.rekeyCards({cardId}, into, adopt: from == null);
for (final adopt in [true, false]) {
final ids = {
for (final MapEntry(key: id, value: key) in rekeyed.entries)
if ((key == null) == adopt) id,
};
if (ids.isNotEmpty) {
await _privacy.rekeyCards(ids, into, adopt: adopt);
}
}
}
// Only the named columns change (ADR 0001, rule 1).
await protocol.Card.db.updateRow(
// Only the named columns change (ADR 0001, rule 1), every card in one
// write.
await protocol.Card.db.update(
_db,
card.copyWith(
studysetId: protocol.UuidValue.withValidation(studysetId),
position: position,
),
[
for (final card in cards)
card.copyWith(
studysetId: protocol.UuidValue.withValidation(studysetId),
position: positions[card.id!.uuid],
),
],
columns: (t) => [t.studysetId, t.position],
);
_sync.requestSync();

View file

@ -353,187 +353,196 @@ class _BrowserState() extends State<_Browser> {
);
void study() => context.push(RoutePaths.study(widget.studyset.id));
void write() => context.push(RoutePaths.newCard(widget.studyset.id));
return BlocBuilder<CardBrowserBloc, CardBrowserState>(
builder: (context, state) {
final body = switch (state) {
CardBrowserState(failed: true, loaded: false) => _Failure(
onRetry: () => _bloc.add(const CardBrowserEventStarted()),
),
CardBrowserState(loaded: false) => const Center(
child: AppLoadingIndicator(),
),
CardBrowserState(cards: []) => _Empty(
onCreate: () =>
context.push(RoutePaths.newCard(widget.studyset.id)),
),
_ => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Toolbar(
detailed: detailed && !state.selecting,
// Choosing cards is the grid's: the view comes back after.
onDetailed: state.selecting ? null : _setDetailed,
searching: _searchOpen || state.filter.query.isNotEmpty,
onSearch: _toggleSearch,
sort: state.sort,
manualSort: !widget.subtree,
onSort: (sort) => _bloc.add(CardBrowserEventSortChanged(sort)),
filtered:
state.filter.kind != null ||
state.filter.tagId != null ||
state.filter.states.isNotEmpty,
onFilter: () => _filter(state, allTags),
selecting: state.selecting,
onSelect: () =>
_bloc.add(const CardBrowserEventSelectingToggled()),
),
if (_searchOpen)
_SearchRow(
controller: _query,
focusNode: _queryFocus,
onChanged: _search,
onClose: _toggleSearch,
// Cards moved show where they go at once; a move that fails puts them
// back, and says so in place of the message that they moved.
return BlocListener<CardBrowserBloc, CardBrowserState>(
listenWhen: (before, now) => now.moveFailures > before.moveFailures,
listener: (context, state) => ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(l10n.cardBrowserMoveFailed))),
child: BlocBuilder<CardBrowserBloc, CardBrowserState>(
builder: (context, state) {
final body = switch (state) {
CardBrowserState(failed: true, loaded: false) => _Failure(
onRetry: () => _bloc.add(const CardBrowserEventStarted()),
),
CardBrowserState(loaded: false) => const Center(
child: AppLoadingIndicator(),
),
CardBrowserState(cards: []) => _Empty(
onCreate: () =>
context.push(RoutePaths.newCard(widget.studyset.id)),
),
_ => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Toolbar(
detailed: detailed && !state.selecting,
// Choosing cards is the grid's: the view comes back after.
onDetailed: state.selecting ? null : _setDetailed,
searching: _searchOpen || state.filter.query.isNotEmpty,
onSearch: _toggleSearch,
sort: state.sort,
manualSort: !widget.subtree,
onSort: (sort) =>
_bloc.add(CardBrowserEventSortChanged(sort)),
filtered:
state.filter.kind != null ||
state.filter.tagId != null ||
state.filter.states.isNotEmpty,
onFilter: () => _filter(state, allTags),
selecting: state.selecting,
onSelect: () =>
_bloc.add(const CardBrowserEventSelectingToggled()),
),
if (state.selecting)
_SelectionControls(
state: state,
actions: expanded ? _actions(state) : null,
if (_searchOpen)
_SearchRow(
controller: _query,
focusNode: _queryFocus,
onChanged: _search,
onClose: _toggleSearch,
),
if (state.selecting)
_SelectionControls(
state: state,
actions: expanded ? _actions(state) : null,
),
// Keyed: rows coming and going above don't make it anew.
DictionaryCorrections(
key: const ValueKey('corrections'),
cards: [for (final each in state.cards) each.card],
),
// Keyed: rows coming and going above don't make it anew.
DictionaryCorrections(
key: const ValueKey('corrections'),
cards: [for (final each in state.cards) each.card],
),
const Divider(height: 1),
Expanded(
child: state.shown.isEmpty
? _NoMatches(onClear: _clearFilters)
// Cards are chosen in the grid, where each shows whether
// it is chosen; the detail's tap opens a card.
: detailed && !state.selecting
? _Detail(
cards: state.shown,
current: _current,
onCurrent: (id) => setState(() => _current = id),
onOpen: _open,
)
: _Grid(
state: state,
onOpen: _open,
onSelect: (id, {required range}) => _bloc.add(
CardBrowserEventCardSelected(id, range: range),
const Divider(height: 1),
Expanded(
child: state.shown.isEmpty
? _NoMatches(onClear: _clearFilters)
// Cards are chosen in the grid, where each shows whether
// it is chosen; the detail's tap opens a card.
: detailed && !state.selecting
? _Detail(
cards: state.shown,
current: _current,
onCurrent: (id) => setState(() => _current = id),
onOpen: _open,
)
: _Grid(
state: state,
onOpen: _open,
onSelect: (id, {required range}) => _bloc.add(
CardBrowserEventCardSelected(id, range: range),
),
),
),
),
],
),
};
return TopGradient(
background: Theme.of(context).colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
// How many cards there are beside the name, as the material's
// tabs say it; with the sub-studysets' cards, a line below
// says so rather than a suffix lengthening the name.
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(base, overflow: TextOverflow.ellipsis),
),
if (context.select<CardBrowserBloc, int?>(
(bloc) => bloc.state.loaded
? bloc.state.cards.length
: null,
)
case final count?) ...[
const SizedBox(width: 8),
CountBadge(count: count),
],
],
),
if (widget.subtree)
Text(
l10n.cardBrowserWithSubstudysets,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
if (!widget.subtree) ...[
// Words where there is room for them; on a phone, icons.
if (context.windowSize.isAtLeastMedium) ...[
FilledButton.icon(
onPressed: study,
icon: const Icon(LucideIcons.play, size: 18),
label: Text(l10n.cardBrowserStudy),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: write,
icon: const Icon(LucideIcons.squarePen, size: 18),
label: Text(l10n.cardBrowserEditor),
),
] else ...[
IconButton.filled(
onPressed: study,
icon: const Icon(LucideIcons.play),
tooltip: l10n.cardBrowserStudy,
),
IconButton(
onPressed: write,
icon: const Icon(LucideIcons.squarePen),
tooltip: l10n.cardBrowserEditor,
),
],
PopupMenuButton<_Menu>(
icon: const Icon(LucideIcons.ellipsisVertical),
tooltip: l10n.cardBrowserMore,
onSelected: (item) => switch (item) {
_Menu.import => unawaited(_import()),
_Menu.tags => unawaited(showManageTagsSheet(context)),
},
itemBuilder: (context) => [
PopupMenuItem(
value: _Menu.import,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
),
),
PopupMenuItem(
value: _Menu.tags,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.tags),
title: Text(l10n.tagPickerManage),
),
),
],
),
],
const SizedBox(width: 4),
),
],
),
body: body,
bottomNavigationBar: state.selecting && !expanded
? _SelectionBar(actions: _actions(state))
: null,
),
);
},
};
return TopGradient(
background: Theme.of(context).colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
// How many cards there are beside the name, as the material's
// tabs say it; with the sub-studysets' cards, a line below
// says so rather than a suffix lengthening the name.
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Flexible(
child: Text(base, overflow: TextOverflow.ellipsis),
),
if (context.select<CardBrowserBloc, int?>(
(bloc) => bloc.state.loaded
? bloc.state.cards.length
: null,
)
case final count?) ...[
const SizedBox(width: 8),
CountBadge(count: count),
],
],
),
if (widget.subtree)
Text(
l10n.cardBrowserWithSubstudysets,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
if (!widget.subtree) ...[
// Words where there is room for them; on a phone, icons.
if (context.windowSize.isAtLeastMedium) ...[
FilledButton.icon(
onPressed: study,
icon: const Icon(LucideIcons.play, size: 18),
label: Text(l10n.cardBrowserStudy),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: write,
icon: const Icon(LucideIcons.squarePen, size: 18),
label: Text(l10n.cardBrowserEditor),
),
] else ...[
IconButton.filled(
onPressed: study,
icon: const Icon(LucideIcons.play),
tooltip: l10n.cardBrowserStudy,
),
IconButton(
onPressed: write,
icon: const Icon(LucideIcons.squarePen),
tooltip: l10n.cardBrowserEditor,
),
],
PopupMenuButton<_Menu>(
icon: const Icon(LucideIcons.ellipsisVertical),
tooltip: l10n.cardBrowserMore,
onSelected: (item) => switch (item) {
_Menu.import => unawaited(_import()),
_Menu.tags => unawaited(showManageTagsSheet(context)),
},
itemBuilder: (context) => [
PopupMenuItem(
value: _Menu.import,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
),
),
PopupMenuItem(
value: _Menu.tags,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.tags),
title: Text(l10n.tagPickerManage),
),
),
],
),
],
const SizedBox(width: 4),
],
),
body: body,
bottomNavigationBar: state.selecting && !expanded
? _SelectionBar(actions: _actions(state))
: null,
),
);
},
),
);
}

View file

@ -373,6 +373,70 @@ void main() {
expect(await sealed(open), isTrue);
});
test('cards moved together are all checked before any moves: one private '
'card among them keeps every one where it was; readable ones moved '
'into a private studyset together are all sealed', () async {
final laptop = await device(account: alice);
final private = await laptop.studysets.insert(
studysetPayload('Diary'),
DateTime.utc(2026, 10, 22),
);
final readable = await laptop.studysets.insert(
studysetPayload('Kitchen German'),
DateTime.utc(2026, 10, 22),
);
await laptop.studysets.setPrivate(readable, private: false);
final elsewhere = await laptop.studysets.insert(
studysetPayload('Cooking'),
DateTime.utc(2026, 10, 22),
);
await laptop.studysets.setPrivate(elsewhere, private: false);
final secret = await laptop.cards.insertCard(
studysetId: private,
position: 'a0',
payload: cardPayload('secret'),
);
final opens = [
for (final front in ['open 1', 'open 2'])
await laptop.cards.insertCard(
studysetId: elsewhere,
position: 'a0',
payload: cardPayload(front),
),
];
Future<String> studysetOf(String card) async =>
(await protocol.Card.db.findById(
db,
protocol.UuidValue.withValidation(card),
))!.studysetId.uuid;
await expectLater(
laptop.cards.updateStudysets(readable, {
opens[0]: 'a1',
secret: 'a2',
opens[1]: 'a3',
}),
throwsA(isA<PrivacyRefused>()),
);
expect(
[for (final card in opens) await studysetOf(card)],
[
elsewhere,
elsewhere,
],
);
expect(await studysetOf(secret), private);
await laptop.cards.updateStudysets(private, {
opens[0]: 'a1',
opens[1]: 'a2',
});
for (final card in opens) {
expect(await studysetOf(card), private);
expect(await sealed(card), isTrue);
}
});
test('a private topic moved into a readable studyset stays private, with a '
'key of its own', () async {
final laptop = await device(account: alice);

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart';
@ -30,9 +32,11 @@ void main() {
late ReviewRepository reviews;
late CardDeletions deletions;
late CardBrowserBloc bloc;
late FakeCardGateway cardGateway;
setUp(() {
cards = CardRepository(FakeCardGateway());
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards);
@ -159,12 +163,84 @@ void main() {
bloc.add(const CardBrowserEventMoved('kana'));
final moved = await until((s) => s.cards.length == 1);
expect(moved.selected, isEmpty);
// Shown moved at once; written a moment after.
await pumpEventQueue();
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[ids[0], ids[1], ids[2]],
);
});
group('cards moved', () {
late List<String> ids;
setUp(() async {
ids = [
for (final front in ['a', 'b', 'c'])
await cards.create('japanese', card(front, 'x')),
];
});
void moveFirstTwo(String into) => bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(ids[0]))
..add(CardBrowserEventCardSelected(ids[1]))
..add(CardBrowserEventMoved(into));
test('elsewhere leave at once, before they are written, and are '
'written together', () async {
start();
await until((s) => s.loaded && s.cards.length == 3);
final hold = cardGateway.holdMoves = Completer<void>();
moveFirstTwo('kana');
final moving = await until((s) => s.cards.length == 1);
expect(fronts(moving), ['c']);
expect(cardGateway.moves, 0, reason: 'not written yet');
hold.complete();
await pumpEventQueue();
expect(cardGateway.moves, 1);
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[
ids[0],
ids[1],
],
);
expect(fronts(bloc.state), ['c']);
});
test('into a studyset the browser shows go there at once', () async {
start(['japanese', 'kana']);
await until((s) => s.loaded && s.cards.length == 3);
cardGateway.holdMoves = Completer<void>();
moveFirstTwo('kana');
final moving = await until(
(s) => s.cards.where((each) => each.studysetId == 'kana').length == 2,
);
expect(fronts(moving), ['c', 'a', 'b']);
});
test('that fail to move are where they were again, and the failure '
'counted for the screen to say', () async {
start();
await until((s) => s.loaded && s.cards.length == 3);
final hold = cardGateway.holdMoves = Completer<void>();
cardGateway.failMoves = Exception('disk full');
moveFirstTwo('kana');
await until((s) => s.cards.length == 1);
hold.complete();
await until((s) => s.moveFailures == 1);
await until((s) => s.cards.length == 3);
expect(fronts(bloc.state), ['a', 'b', 'c']);
expect(await cards.cardsOf('kana'), isEmpty);
});
});
test('a studyset and those inside it are browsed together, in tree '
'order', () async {
await cards.create('japanese', card('top', 'x'));

View file

@ -135,12 +135,38 @@ class FakeCardGateway() implements CardGateway {
return revision.id;
}
/// When set, moving cards fails with it, none moved.
Exception? failMoves;
/// While set, moving cards waits for it to complete.
Completer<void>? holdMoves;
/// How many times cards were moved, however many at once.
int moves = 0;
@override
Future<void> updateStudysets(
String studysetId,
Map<String, String> positions,
) async {
await holdMoves?.future;
if (failMoves case final error?) {
throw error;
}
moves++;
for (final MapEntry(key: id, value: position) in positions.entries) {
_place(id, studysetId, position);
}
}
@override
Future<void> updateStudyset(
String cardId,
String studysetId,
String position,
) async {
) async => _place(cardId, studysetId, position);
void _place(String cardId, String studysetId, String position) {
if (_cards[cardId] case final card?) {
_cards[cardId] = (
studysetId: studysetId,