refactor(app): flashcards keep the order they came in, without Reorder
Studying breaks the order of due cards anyway; what follows the list order is new cards and cramming, and that order comes from creating and importing, with sub-studysets putting lessons in order. No one drags a card among a thousand (decided 2026-10-08). Gone: Reorder and the grip on flashcards, CardListEventMoved, CardRepository.move and the gateway's updatePosition, with their tests. Each card keeps its stored position, so the order stays the same on every device. liftWhileDragged is Material's plain lift again, for the lists that keep Reorder. 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:
parent
bb23ca2478
commit
6c9dfadf27
19 changed files with 97 additions and 410 deletions
|
|
@ -277,7 +277,6 @@
|
|||
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
|
||||
"studysetReorder": "Umsortieren",
|
||||
"studysetReorderPaths": "Pfade umsortieren",
|
||||
"studysetReorderCards": "Karteikarten umsortieren",
|
||||
"studysetReorderDone": "Fertig",
|
||||
"studysetOpenTopics": "Themen anzeigen",
|
||||
"studysetTopics": "Themen",
|
||||
|
|
|
|||
|
|
@ -307,7 +307,6 @@
|
|||
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetReorder": "Reorder",
|
||||
"studysetReorderPaths": "Reorder paths",
|
||||
"studysetReorderCards": "Reorder flashcards",
|
||||
"studysetReorderDone": "Done",
|
||||
"studysetOpenTopics": "Show topics",
|
||||
"studysetTopics": "Topics",
|
||||
|
|
|
|||
|
|
@ -1720,12 +1720,6 @@ abstract class AppLocalizations {
|
|||
/// **'Reorder paths'**
|
||||
String get studysetReorderPaths;
|
||||
|
||||
/// No description provided for @studysetReorderCards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reorder flashcards'**
|
||||
String get studysetReorderCards;
|
||||
|
||||
/// No description provided for @studysetReorderDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1030,9 +1030,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetReorderPaths => 'Pfade umsortieren';
|
||||
|
||||
@override
|
||||
String get studysetReorderCards => 'Karteikarten umsortieren';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Fertig';
|
||||
|
||||
|
|
|
|||
|
|
@ -1023,9 +1023,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetReorderPaths => 'Reorder paths';
|
||||
|
||||
@override
|
||||
String get studysetReorderCards => 'Reorder flashcards';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Done';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,17 @@
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How a row of a reorderable list shows while it is dragged elsewhere:
|
||||
/// rising onto a shadow, as Material's own reorderable lists show it. The
|
||||
/// shadow takes the row's outline, [borderRadius], inset by [inset] for a
|
||||
/// row that keeps space around itself; it is also the row's background, for
|
||||
/// a row without one of its own.
|
||||
ReorderItemProxyDecorator liftWhileDragged({
|
||||
EdgeInsets inset = EdgeInsets.zero,
|
||||
BorderRadius borderRadius = BorderRadius.zero,
|
||||
}) =>
|
||||
(child, index, animation) => AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: inset,
|
||||
child: Material(
|
||||
elevation: 6 * Curves.easeInOut.transform(animation.value),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
?child,
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
/// How a row of a [SliverReorderableList] shows while it is dragged
|
||||
/// elsewhere: rising onto a shadow, on a surface of its own, as Material's
|
||||
/// own reorderable lists show it.
|
||||
Widget liftWhileDragged(
|
||||
Widget child,
|
||||
int index,
|
||||
Animation<double> animation,
|
||||
) => AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Material(
|
||||
elevation: 6 * Curves.easeInOut.transform(animation.value),
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ 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 and which are buried
|
||||
/// for the day, all of them or those with one tag; moves, reports and buries
|
||||
/// for the day, all of them or those with one tag; reports and buries
|
||||
/// them. The list follows every change, local or synced.
|
||||
class CardListBloc(
|
||||
final CardRepository _cards,
|
||||
|
|
@ -21,7 +21,6 @@ class CardListBloc(
|
|||
) extends Bloc<CardListEvent, CardListState> {
|
||||
this : super(const CardListStateLoading()) {
|
||||
on<CardListEventStarted>(_onStarted);
|
||||
on<CardListEventMoved>(_onMoved);
|
||||
on<CardListEventReported>(
|
||||
(event, emit) => _cards.saveEdit(
|
||||
event.card.id,
|
||||
|
|
@ -90,11 +89,6 @@ class CardListBloc(
|
|||
]);
|
||||
}
|
||||
|
||||
Future<void> _onMoved(
|
||||
CardListEventMoved event,
|
||||
Emitter<CardListState> emit,
|
||||
) => _cards.move(_studysetId, event.card, toIndex: event.toIndex);
|
||||
|
||||
/// The list as it stands, or null while the cards are being read.
|
||||
CardListStateLoaded? _loaded() {
|
||||
final cards = _cardsNow;
|
||||
|
|
@ -126,18 +120,6 @@ sealed class const CardListEvent() with Equatable {
|
|||
/// The list opens: follow the studyset's cards and the tags.
|
||||
final class const CardListEventStarted() extends CardListEvent;
|
||||
|
||||
/// Move [card] to [toIndex].
|
||||
final class const CardListEventMoved(
|
||||
/// The card to move.
|
||||
final Card card, {
|
||||
|
||||
/// Where it goes, counted in the list without it.
|
||||
required final int toIndex,
|
||||
}) extends CardListEvent {
|
||||
@override
|
||||
List<Object?> get props => [card, toIndex];
|
||||
}
|
||||
|
||||
/// Show only the cards with the tag [tagId], or all when it is null.
|
||||
final class const CardListEventTagFilterChanged(
|
||||
/// The tag's id.
|
||||
|
|
|
|||
|
|
@ -52,9 +52,6 @@ abstract interface class CardGateway() {
|
|||
required ByteData payload,
|
||||
});
|
||||
|
||||
/// Moves the card [cardId] to [position]; the only column that changes.
|
||||
Future<void> updatePosition(String cardId, String position);
|
||||
|
||||
/// Buries the card [cardId] at [buriedAt], or unburies it with null; the
|
||||
/// only column that changes.
|
||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt);
|
||||
|
|
|
|||
|
|
@ -216,34 +216,6 @@ class CardRepository(final CardGateway _gateway) {
|
|||
return mergeRevisions(revisions)?.heads ?? const {};
|
||||
}
|
||||
|
||||
/// Moves [card] to [toIndex] among the cards of [studysetId].
|
||||
Future<void> move(
|
||||
String studysetId,
|
||||
Card card, {
|
||||
required int toIndex,
|
||||
}) async {
|
||||
final others = [
|
||||
for (final other in await cardsOf(studysetId))
|
||||
if (other.id != card.id) other,
|
||||
];
|
||||
final index = toIndex.clamp(0, others.length);
|
||||
final lower = index > 0 ? others[index - 1].position : null;
|
||||
// Two devices appending at once can pick the same position; the key then
|
||||
// goes after every card sharing the lower one.
|
||||
String? upper;
|
||||
for (final other in others.skip(index)) {
|
||||
if (lower == null || other.position.compareTo(lower) > 0) {
|
||||
upper = other.position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await _gateway.updatePosition(
|
||||
card.id,
|
||||
FractionalIndexing.generateKeyBetween(lower, upper),
|
||||
);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Buries the card [cardId] at [at]: it isn't studied until the next local
|
||||
/// day begins ([Card.isBuried]).
|
||||
Future<void> bury(String cardId, DateTime at) async {
|
||||
|
|
|
|||
|
|
@ -142,24 +142,6 @@ class SyncCardGateway(
|
|||
return revision.id!.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePosition(String cardId, String position) async {
|
||||
final card = await protocol.Card.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(cardId),
|
||||
);
|
||||
if (card == null) {
|
||||
return;
|
||||
}
|
||||
// Only the named column changes (ADR 0001, rule 1).
|
||||
await protocol.Card.db.updateRow(
|
||||
_db,
|
||||
card.copyWith(position: position),
|
||||
columns: (t) => [t.position],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
||||
final card = await protocol.Card.db.findById(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.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';
|
||||
|
|
@ -47,11 +46,12 @@ class const CardListScope({
|
|||
}
|
||||
|
||||
/// A studyset's cards: in order, each with its tags and opening its editor,
|
||||
/// all of them or those with one tag; from the [CardListScope] above. While
|
||||
/// [reordering] all of them, each has a handle to drag it elsewhere instead
|
||||
/// of its options. Slivers, for a scrolling view to hold: the cards are
|
||||
/// built as they scroll into view, so a studyset of thousands opens as fast
|
||||
/// as one of ten. Adding and studying are the screen's.
|
||||
/// all of them or those with one tag; from the [CardListScope] above. They
|
||||
/// keep the order they were created or imported in, which new cards and
|
||||
/// cramming follow: no one drags a card among a thousand (decided
|
||||
/// 2026-10-08). Slivers, for a scrolling view to hold: the cards are built
|
||||
/// as they scroll into view, so a studyset of thousands opens as fast as one
|
||||
/// of ten. Adding and studying are the screen's.
|
||||
class const SliverCardList({
|
||||
/// The person's cards.
|
||||
required final CardRepository cards,
|
||||
|
|
@ -61,9 +61,6 @@ class const SliverCardList({
|
|||
|
||||
/// The space around the cards in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// Whether the cards show handles to drag them by.
|
||||
final bool reordering = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -97,28 +94,14 @@ class const SliverCardList({
|
|||
loaded.tagging,
|
||||
buried: loaded.buried,
|
||||
padding: padding,
|
||||
// Not while only some show, as the places between them
|
||||
// are unclear.
|
||||
reordering: reordering && loaded.filter == null,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Beside the cards: reordering them, narrowing them to a tag, and managing
|
||||
/// the tags; from the [CardListScope] above, once there are cards. While
|
||||
/// [reordering], only Done.
|
||||
class const CardListToolbar({
|
||||
/// Whether the cards show handles to drag them by.
|
||||
final bool reordering = false,
|
||||
|
||||
/// Whether there is room for the word Reorder beside its icon.
|
||||
final bool labelled = true,
|
||||
|
||||
/// Starts reordering the cards, or ends it.
|
||||
final VoidCallback? onReorderToggled,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// Beside the cards: narrowing them to a tag, and managing the tags; from
|
||||
/// the [CardListScope] above, once there are cards.
|
||||
class const CardListToolbar({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<CardListBloc>().state;
|
||||
|
|
@ -127,23 +110,9 @@ class const CardListToolbar({
|
|||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final reorder = switch (onReorderToggled) {
|
||||
final onToggle? => ReorderToggle(
|
||||
reordering: reordering,
|
||||
labelled: labelled,
|
||||
tooltip: l10n.studysetReorderCards,
|
||||
onToggle: onToggle,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
if (reordering) {
|
||||
return reorder ?? const SizedBox.shrink();
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// With two cards or more, all of them showing.
|
||||
if (state.filter == null && state.cards.length > 1) ?reorder,
|
||||
IconButton(
|
||||
tooltip: l10n.cardFilterTooltip,
|
||||
icon: Icon(
|
||||
|
|
@ -314,9 +283,6 @@ class const _SliverCards(
|
|||
|
||||
/// The space around the cards.
|
||||
required final EdgeInsets padding,
|
||||
|
||||
/// Whether the cards show handles to drag them by.
|
||||
required final bool reordering,
|
||||
}) extends StatelessWidget {
|
||||
/// Marks [card] with [report] (null resolving it), saying so with a way
|
||||
/// back to how it was.
|
||||
|
|
@ -373,7 +339,6 @@ class const _SliverCards(
|
|||
card: card,
|
||||
tags: tagging.of(card.id),
|
||||
buried: buried.contains(card.id),
|
||||
dragIndex: reordering ? index : null,
|
||||
onReport: () async {
|
||||
if (await showReportSheet(context) case final report?
|
||||
when context.mounted) {
|
||||
|
|
@ -410,25 +375,16 @@ class const _SliverCards(
|
|||
|
||||
return SliverPadding(
|
||||
padding: padding,
|
||||
sliver: reordering
|
||||
? SliverReorderableList(
|
||||
itemCount: cards.length,
|
||||
onReorderItem: (from, to) => context.read<CardListBloc>().add(
|
||||
CardListEventMoved(cards[from], toIndex: to),
|
||||
),
|
||||
proxyDecorator: CardTile.lifted,
|
||||
itemBuilder: (context, index) => tile(index),
|
||||
)
|
||||
: SliverList.builder(
|
||||
itemCount: cards.length,
|
||||
// A card keeps its place in the tree when others come or go
|
||||
// before it, an open menu with it.
|
||||
findChildIndexCallback: (key) => switch (key) {
|
||||
ValueKey<String>(:final value) => indexOf[value],
|
||||
_ => null,
|
||||
},
|
||||
itemBuilder: (context, index) => tile(index),
|
||||
),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: cards.length,
|
||||
// A card keeps its place in the tree when others come or go before
|
||||
// it, an open menu with it.
|
||||
findChildIndexCallback: (key) => switch (key) {
|
||||
ValueKey<String>(:final value) => indexOf[value],
|
||||
_ => null,
|
||||
},
|
||||
itemBuilder: (context, index) => tile(index),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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/core/widget/lift_while_dragged.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';
|
||||
|
|
@ -12,8 +11,7 @@ import 'package:material_ui/material_ui.dart' hide Card;
|
|||
/// A card in a studyset's list: a bordered row of one height showing only
|
||||
/// the front, so a long list stays easy to skim, with the card's tags above
|
||||
/// it and a badge when devices changed it differently. Tapping opens it;
|
||||
/// "…" offers editing and deleting. While its list is reordered, a handle to
|
||||
/// drag it by replaces "…", and a tap does nothing.
|
||||
/// "…" offers editing and deleting.
|
||||
class const CardTile({
|
||||
/// The card.
|
||||
required final Card card,
|
||||
|
|
@ -41,20 +39,8 @@ class const CardTile({
|
|||
|
||||
/// Whether it is buried for the day.
|
||||
final bool buried = false,
|
||||
|
||||
/// Its place in a list being reordered, where its handle drags it from;
|
||||
/// null while the list isn't.
|
||||
final int? dragIndex,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The space above and below each card, between it and the next.
|
||||
static const _gap = EdgeInsets.symmetric(vertical: 4);
|
||||
|
||||
static const _radius = BorderRadius.all(Radius.circular(12));
|
||||
|
||||
/// A card dragged elsewhere, rising onto a shadow of its own outline.
|
||||
static final lifted = liftWhileDragged(inset: _gap, borderRadius: _radius);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -64,16 +50,16 @@ class const CardTile({
|
|||
final hasMeta = tags.isNotEmpty;
|
||||
final hasBadges = report != null || buried;
|
||||
return Padding(
|
||||
padding: _gap,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: _radius,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: dragIndex == null ? onOpen : null,
|
||||
borderRadius: _radius,
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: _radius,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
// At least this high, so rows are even, yet free to grow with
|
||||
|
|
@ -143,82 +129,69 @@ class const CardTile({
|
|||
],
|
||||
),
|
||||
),
|
||||
if (dragIndex case final index?)
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: IconButton(
|
||||
tooltip: l10n.homeDragToReorder,
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
LucideIcons.gripHorizontal,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(
|
||||
LucideIcons.pencil,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: onOpen,
|
||||
child: Text(l10n.cardTileEdit),
|
||||
),
|
||||
)
|
||||
else
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
if (report == null)
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(
|
||||
LucideIcons.pencil,
|
||||
LucideIcons.flag,
|
||||
size: 18,
|
||||
),
|
||||
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),
|
||||
),
|
||||
onPressed: onReport,
|
||||
child: Text(l10n.cardReportTitle),
|
||||
)
|
||||
else
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
buried ? LucideIcons.sunrise : LucideIcons.moon,
|
||||
leadingIcon: const Icon(
|
||||
LucideIcons.flagOff,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: onBuryToggle,
|
||||
child: Text(
|
||||
buried ? l10n.cardUnbury : l10n.studyBuryCard,
|
||||
),
|
||||
onPressed: onResolve,
|
||||
child: Text(l10n.cardResolveReport),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
child: Text(
|
||||
l10n.cardTileDelete,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
buried ? LucideIcons.sunrise : LucideIcons.moon,
|
||||
size: 18,
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.info, size: 18),
|
||||
onPressed: onInfo,
|
||||
child: Text(l10n.moreInfo),
|
||||
onPressed: onBuryToggle,
|
||||
child: Text(
|
||||
buried ? l10n.cardUnbury : l10n.studyBuryCard,
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => IconButton(
|
||||
tooltip: l10n.cardTileOptions,
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
onPressed: () =>
|
||||
menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
child: Text(
|
||||
l10n.cardTileDelete,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.info, size: 18),
|
||||
onPressed: onInfo,
|
||||
child: Text(l10n.moreInfo),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => IconButton(
|
||||
tooltip: l10n.cardTileOptions,
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
onPressed: () =>
|
||||
menu.isOpen ? menu.close() : menu.open(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class const SliverPathList({
|
|||
onReorderItem: (from, to) => context.read<PathListBloc>().add(
|
||||
PathListEventMoveRequested(paths[from].id, to),
|
||||
),
|
||||
proxyDecorator: liftWhileDragged(),
|
||||
proxyDecorator: liftWhileDragged,
|
||||
itemBuilder: (context, index) {
|
||||
final path = paths[index];
|
||||
return ListTile(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
|||
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/bloc/card_list_bloc.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/export/widget/export_studyset.dart';
|
||||
|
|
@ -467,9 +466,6 @@ class _ContentState()
|
|||
/// Whether the paths show handles to drag them.
|
||||
var _reorderingPaths = false;
|
||||
|
||||
/// Whether the flashcards show handles to drag them.
|
||||
var _reorderingCards = false;
|
||||
|
||||
/// Each tab's scrolling, to go back to its top.
|
||||
final _scrolls = [
|
||||
for (final _ in StudysetTab.values) ScrollController(),
|
||||
|
|
@ -600,17 +596,6 @@ class _ContentState()
|
|||
}
|
||||
},
|
||||
),
|
||||
BlocListener<CardListBloc, CardListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder, or only some cards showing.
|
||||
if (_reorderingCards &&
|
||||
(state is! CardListStateLoaded ||
|
||||
state.cards.length < 2 ||
|
||||
state.filter != null)) {
|
||||
setState(() => _reorderingCards = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
@ -645,16 +630,9 @@ class _ContentState()
|
|||
animation: _tabs,
|
||||
builder: (context, _) =>
|
||||
switch (StudysetTab.values[_tabs.index]) {
|
||||
StudysetTab.flashcards => Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CardListToolbar(
|
||||
reordering: _reorderingCards,
|
||||
// Below that, an icon alone.
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
onReorderToggled: () => setState(
|
||||
() => _reorderingCards = !_reorderingCards,
|
||||
),
|
||||
),
|
||||
StudysetTab.flashcards => const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: CardListToolbar(),
|
||||
),
|
||||
StudysetTab.paths => _ReorderPaths(
|
||||
reordering: _reorderingPaths,
|
||||
|
|
@ -688,7 +666,6 @@ class _ContentState()
|
|||
SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingCards,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -95,30 +95,6 @@ void main() {
|
|||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('moves a card', () async {
|
||||
await cards.create('japanese', card('a', ''));
|
||||
await cards.create('japanese', card('b', ''));
|
||||
final bloc = CardListBloc(cards, tags, deletions, 'japanese')
|
||||
..add(const CardListEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
final loaded = await bloc.stream.firstWhere(
|
||||
(state) => state is CardListStateLoaded,
|
||||
) as CardListStateLoaded;
|
||||
|
||||
bloc.add(CardListEventMoved(loaded.cards.last, toIndex: 0));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<CardListStateLoaded>().having(
|
||||
(s) => [for (final card in s.cards) card.content.front],
|
||||
'fronts',
|
||||
[text('b'), text('a')],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('CardListBloc with tags', () {
|
||||
|
|
|
|||
|
|
@ -195,25 +195,6 @@ void main() {
|
|||
expect(await repository.headsOf('card-9999'), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'moves a card between two others, to the start and to the end',
|
||||
() async {
|
||||
for (final front in ['a', 'b', 'c']) {
|
||||
await repository.create('japanese', card(front, ''));
|
||||
}
|
||||
Future<List<Card>> cards() => repository.cardsOf('japanese');
|
||||
|
||||
await repository.move('japanese', (await cards())[2], toIndex: 1);
|
||||
expect(fronts(await cards()), ['a', 'c', 'b']);
|
||||
|
||||
await repository.move('japanese', (await cards())[2], toIndex: 0);
|
||||
expect(fronts(await cards()), ['b', 'a', 'c']);
|
||||
|
||||
await repository.move('japanese', (await cards())[0], toIndex: 2);
|
||||
expect(fronts(await cards()), ['a', 'c', 'b']);
|
||||
},
|
||||
);
|
||||
|
||||
test('deleting blanks every revision before the card goes', () async {
|
||||
final id = await repository.create('japanese', card('秘密', 'secret'));
|
||||
await repository.save(
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ void main() {
|
|||
|
||||
testWidgets(
|
||||
"on desktop, a card's whole … opens its options, with no handle to "
|
||||
'drag it over it',
|
||||
'drag it over it: cards keep the order they came in',
|
||||
(tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await cards.create('japanese', card('犬', 'dog'));
|
||||
|
|
@ -247,6 +247,7 @@ void main() {
|
|||
|
||||
expect(find.byIcon(Icons.drag_handle), findsNothing);
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
expect(find.text('Reorder'), findsNothing);
|
||||
await tester.tap(optionsOf('猫'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
|
|
@ -254,54 +255,6 @@ void main() {
|
|||
variant: TargetPlatformVariant.only(TargetPlatform.linux),
|
||||
);
|
||||
|
||||
testWidgets('Reorder beside the tabs gives each card a handle to drag it '
|
||||
'by instead of its options, until Done', (tester) async {
|
||||
final first = await cards.create('japanese', card('猫', 'cat'));
|
||||
final second = await cards.create('japanese', card('犬', 'dog'));
|
||||
await showList(tester);
|
||||
|
||||
await tester.tap(find.text('Reorder'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Drag to reorder'), findsNWidgets(2));
|
||||
expect(optionsOf('猫'), findsNothing);
|
||||
|
||||
// The first card, dragged below the second.
|
||||
final rowHeight = tester.getSize(find.widgetWithText(CardTile, '猫')).height;
|
||||
final drag = await tester.startGesture(
|
||||
tester.getCenter(find.byTooltip('Drag to reorder').first),
|
||||
);
|
||||
for (var step = 0; step < 6; step++) {
|
||||
await drag.moveBy(Offset(0, rowHeight * 1.5 / 6));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
await drag.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
[
|
||||
for (final card in await cards.cardsOf('japanese')) card.id,
|
||||
],
|
||||
[second, first],
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('犬')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('猫')).dy),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
expect(optionsOf('猫'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Reorder shows with two cards or more, all of them showing', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
await showList(tester);
|
||||
|
||||
expect(find.text('Reorder'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('builds only the cards in view, so a studyset of a thousand '
|
||||
'opens as fast as one of ten', (tester) async {
|
||||
await cards.createAll('japanese', [
|
||||
|
|
|
|||
|
|
@ -129,18 +129,6 @@ class FakeCardGateway() implements CardGateway {
|
|||
return revision.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePosition(String cardId, String position) async {
|
||||
if (_cards[cardId] case final card?) {
|
||||
_cards[cardId] = (
|
||||
studysetId: card.studysetId,
|
||||
position: position,
|
||||
buriedAt: card.buriedAt,
|
||||
createdAt: card.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
||||
if (_cards[cardId] case final card?) {
|
||||
|
|
|
|||
|
|
@ -131,30 +131,6 @@ void main() {
|
|||
expect(resolved.content.back, text('a cat'));
|
||||
});
|
||||
|
||||
test('a card buried on one device and moved on another at once is both '
|
||||
'on each', () async {
|
||||
final phone = await device('phone');
|
||||
final laptop = await device('laptop');
|
||||
final (studyset, id) = await shared(phone, laptop);
|
||||
await phone.cards.create(studyset, card('犬', 'dog'));
|
||||
await syncBoth(phone, laptop);
|
||||
final at = DateTime.utc(2026, 10, 7, 9);
|
||||
|
||||
await phone.cards.bury(id, at);
|
||||
await laptop.cards.move(
|
||||
studyset,
|
||||
(await laptop.card(studyset, id))!,
|
||||
toIndex: 1,
|
||||
);
|
||||
await syncBoth(phone, laptop);
|
||||
|
||||
for (final device in [phone, laptop]) {
|
||||
final moved = (await device.cards.cardsOf(studyset)).last;
|
||||
expect(moved.id, id);
|
||||
expect(moved.buriedAt, at);
|
||||
}
|
||||
});
|
||||
|
||||
test('ratings on two devices both count, and both devices compute the '
|
||||
'same schedule', () async {
|
||||
final phone = await device('phone');
|
||||
|
|
|
|||
Loading…
Reference in a new issue