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
20 changed files with 97 additions and 410 deletions
|
|
@ -98,7 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
|
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
|
||||||
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
||||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `liftWhileDragged` and `SliverCentered`, and the sheets below).
|
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `liftWhileDragged` and `SliverCentered`, and the sheets below).
|
||||||
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a grip in place of each row's options), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button.
|
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a grip in place of each row's options), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand.
|
||||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
|
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
|
||||||
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,6 @@
|
||||||
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
|
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
|
||||||
"studysetReorder": "Umsortieren",
|
"studysetReorder": "Umsortieren",
|
||||||
"studysetReorderPaths": "Pfade umsortieren",
|
"studysetReorderPaths": "Pfade umsortieren",
|
||||||
"studysetReorderCards": "Karteikarten umsortieren",
|
|
||||||
"studysetReorderDone": "Fertig",
|
"studysetReorderDone": "Fertig",
|
||||||
"studysetOpenTopics": "Themen anzeigen",
|
"studysetOpenTopics": "Themen anzeigen",
|
||||||
"studysetTopics": "Themen",
|
"studysetTopics": "Themen",
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,6 @@
|
||||||
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
|
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"studysetReorder": "Reorder",
|
"studysetReorder": "Reorder",
|
||||||
"studysetReorderPaths": "Reorder paths",
|
"studysetReorderPaths": "Reorder paths",
|
||||||
"studysetReorderCards": "Reorder flashcards",
|
|
||||||
"studysetReorderDone": "Done",
|
"studysetReorderDone": "Done",
|
||||||
"studysetOpenTopics": "Show topics",
|
"studysetOpenTopics": "Show topics",
|
||||||
"studysetTopics": "Topics",
|
"studysetTopics": "Topics",
|
||||||
|
|
|
||||||
|
|
@ -1720,12 +1720,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Reorder paths'**
|
/// **'Reorder paths'**
|
||||||
String get studysetReorderPaths;
|
String get studysetReorderPaths;
|
||||||
|
|
||||||
/// No description provided for @studysetReorderCards.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Reorder flashcards'**
|
|
||||||
String get studysetReorderCards;
|
|
||||||
|
|
||||||
/// No description provided for @studysetReorderDone.
|
/// No description provided for @studysetReorderDone.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1030,9 +1030,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetReorderPaths => 'Pfade umsortieren';
|
String get studysetReorderPaths => 'Pfade umsortieren';
|
||||||
|
|
||||||
@override
|
|
||||||
String get studysetReorderCards => 'Karteikarten umsortieren';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetReorderDone => 'Fertig';
|
String get studysetReorderDone => 'Fertig';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1023,9 +1023,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetReorderPaths => 'Reorder paths';
|
String get studysetReorderPaths => 'Reorder paths';
|
||||||
|
|
||||||
@override
|
|
||||||
String get studysetReorderCards => 'Reorder flashcards';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetReorderDone => 'Done';
|
String get studysetReorderDone => 'Done';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,17 @@
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// How a row of a reorderable list shows while it is dragged elsewhere:
|
/// How a row of a [SliverReorderableList] shows while it is dragged
|
||||||
/// rising onto a shadow, as Material's own reorderable lists show it. The
|
/// elsewhere: rising onto a shadow, on a surface of its own, as Material's
|
||||||
/// shadow takes the row's outline, [borderRadius], inset by [inset] for a
|
/// own reorderable lists show it.
|
||||||
/// row that keeps space around itself; it is also the row's background, for
|
Widget liftWhileDragged(
|
||||||
/// a row without one of its own.
|
Widget child,
|
||||||
ReorderItemProxyDecorator liftWhileDragged({
|
int index,
|
||||||
EdgeInsets inset = EdgeInsets.zero,
|
Animation<double> animation,
|
||||||
BorderRadius borderRadius = BorderRadius.zero,
|
) => AnimatedBuilder(
|
||||||
}) =>
|
animation: animation,
|
||||||
(child, index, animation) => AnimatedBuilder(
|
builder: (context, child) => Material(
|
||||||
animation: animation,
|
elevation: 6 * Curves.easeInOut.transform(animation.value),
|
||||||
builder: (context, child) => Stack(
|
child: child,
|
||||||
children: [
|
),
|
||||||
Positioned.fill(
|
child: child,
|
||||||
child: Padding(
|
);
|
||||||
padding: inset,
|
|
||||||
child: Material(
|
|
||||||
elevation: 6 * Curves.easeInOut.transform(animation.value),
|
|
||||||
borderRadius: borderRadius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
?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';
|
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
|
/// 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.
|
/// them. The list follows every change, local or synced.
|
||||||
class CardListBloc(
|
class CardListBloc(
|
||||||
final CardRepository _cards,
|
final CardRepository _cards,
|
||||||
|
|
@ -21,7 +21,6 @@ class CardListBloc(
|
||||||
) extends Bloc<CardListEvent, CardListState> {
|
) extends Bloc<CardListEvent, CardListState> {
|
||||||
this : super(const CardListStateLoading()) {
|
this : super(const CardListStateLoading()) {
|
||||||
on<CardListEventStarted>(_onStarted);
|
on<CardListEventStarted>(_onStarted);
|
||||||
on<CardListEventMoved>(_onMoved);
|
|
||||||
on<CardListEventReported>(
|
on<CardListEventReported>(
|
||||||
(event, emit) => _cards.saveEdit(
|
(event, emit) => _cards.saveEdit(
|
||||||
event.card.id,
|
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.
|
/// The list as it stands, or null while the cards are being read.
|
||||||
CardListStateLoaded? _loaded() {
|
CardListStateLoaded? _loaded() {
|
||||||
final cards = _cardsNow;
|
final cards = _cardsNow;
|
||||||
|
|
@ -126,18 +120,6 @@ sealed class const CardListEvent() with Equatable {
|
||||||
/// The list opens: follow the studyset's cards and the tags.
|
/// The list opens: follow the studyset's cards and the tags.
|
||||||
final class const CardListEventStarted() extends CardListEvent;
|
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.
|
/// Show only the cards with the tag [tagId], or all when it is null.
|
||||||
final class const CardListEventTagFilterChanged(
|
final class const CardListEventTagFilterChanged(
|
||||||
/// The tag's id.
|
/// The tag's id.
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,6 @@ abstract interface class CardGateway() {
|
||||||
required ByteData payload,
|
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
|
/// Buries the card [cardId] at [buriedAt], or unburies it with null; the
|
||||||
/// only column that changes.
|
/// only column that changes.
|
||||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt);
|
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt);
|
||||||
|
|
|
||||||
|
|
@ -216,34 +216,6 @@ class CardRepository(final CardGateway _gateway) {
|
||||||
return mergeRevisions(revisions)?.heads ?? const {};
|
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
|
/// Buries the card [cardId] at [at]: it isn't studied until the next local
|
||||||
/// day begins ([Card.isBuried]).
|
/// day begins ([Card.isBuried]).
|
||||||
Future<void> bury(String cardId, DateTime at) async {
|
Future<void> bury(String cardId, DateTime at) async {
|
||||||
|
|
|
||||||
|
|
@ -142,24 +142,6 @@ class SyncCardGateway(
|
||||||
return revision.id!.uuid;
|
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
|
@override
|
||||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
||||||
final card = await protocol.Card.db.findById(
|
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/adaptive_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.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/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/sliver_centered.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/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,
|
/// 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
|
/// all of them or those with one tag; from the [CardListScope] above. They
|
||||||
/// [reordering] all of them, each has a handle to drag it elsewhere instead
|
/// keep the order they were created or imported in, which new cards and
|
||||||
/// of its options. Slivers, for a scrolling view to hold: the cards are
|
/// cramming follow: no one drags a card among a thousand (decided
|
||||||
/// built as they scroll into view, so a studyset of thousands opens as fast
|
/// 2026-10-08). Slivers, for a scrolling view to hold: the cards are built
|
||||||
/// as one of ten. Adding and studying are the screen's.
|
/// 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({
|
class const SliverCardList({
|
||||||
/// The person's cards.
|
/// The person's cards.
|
||||||
required final CardRepository cards,
|
required final CardRepository cards,
|
||||||
|
|
@ -61,9 +61,6 @@ class const SliverCardList({
|
||||||
|
|
||||||
/// The space around the cards in the scrolling view.
|
/// The space around the cards in the scrolling view.
|
||||||
final EdgeInsets padding = EdgeInsets.zero,
|
final EdgeInsets padding = EdgeInsets.zero,
|
||||||
|
|
||||||
/// Whether the cards show handles to drag them by.
|
|
||||||
final bool reordering = false,
|
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -97,28 +94,14 @@ class const SliverCardList({
|
||||||
loaded.tagging,
|
loaded.tagging,
|
||||||
buried: loaded.buried,
|
buried: loaded.buried,
|
||||||
padding: padding,
|
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
|
/// Beside the cards: narrowing them to a tag, and managing the tags; from
|
||||||
/// the tags; from the [CardListScope] above, once there are cards. While
|
/// the [CardListScope] above, once there are cards.
|
||||||
/// [reordering], only Done.
|
class const CardListToolbar({super.key}) extends StatelessWidget {
|
||||||
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 {
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = context.watch<CardListBloc>().state;
|
final state = context.watch<CardListBloc>().state;
|
||||||
|
|
@ -127,23 +110,9 @@ class const CardListToolbar({
|
||||||
}
|
}
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.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(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// With two cards or more, all of them showing.
|
|
||||||
if (state.filter == null && state.cards.length > 1) ?reorder,
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.cardFilterTooltip,
|
tooltip: l10n.cardFilterTooltip,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
|
|
@ -314,9 +283,6 @@ class const _SliverCards(
|
||||||
|
|
||||||
/// The space around the cards.
|
/// The space around the cards.
|
||||||
required final EdgeInsets padding,
|
required final EdgeInsets padding,
|
||||||
|
|
||||||
/// Whether the cards show handles to drag them by.
|
|
||||||
required final bool reordering,
|
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
/// Marks [card] with [report] (null resolving it), saying so with a way
|
/// Marks [card] with [report] (null resolving it), saying so with a way
|
||||||
/// back to how it was.
|
/// back to how it was.
|
||||||
|
|
@ -373,7 +339,6 @@ class const _SliverCards(
|
||||||
card: card,
|
card: card,
|
||||||
tags: tagging.of(card.id),
|
tags: tagging.of(card.id),
|
||||||
buried: buried.contains(card.id),
|
buried: buried.contains(card.id),
|
||||||
dragIndex: reordering ? index : null,
|
|
||||||
onReport: () async {
|
onReport: () async {
|
||||||
if (await showReportSheet(context) case final report?
|
if (await showReportSheet(context) case final report?
|
||||||
when context.mounted) {
|
when context.mounted) {
|
||||||
|
|
@ -410,25 +375,16 @@ class const _SliverCards(
|
||||||
|
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
sliver: reordering
|
sliver: SliverList.builder(
|
||||||
? SliverReorderableList(
|
itemCount: cards.length,
|
||||||
itemCount: cards.length,
|
// A card keeps its place in the tree when others come or go before
|
||||||
onReorderItem: (from, to) => context.read<CardListBloc>().add(
|
// it, an open menu with it.
|
||||||
CardListEventMoved(cards[from], toIndex: to),
|
findChildIndexCallback: (key) => switch (key) {
|
||||||
),
|
ValueKey<String>(:final value) => indexOf[value],
|
||||||
proxyDecorator: CardTile.lifted,
|
_ => null,
|
||||||
itemBuilder: (context, index) => tile(index),
|
},
|
||||||
)
|
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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
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/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/model/card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/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
|
/// 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
|
/// 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;
|
/// it and a badge when devices changed it differently. Tapping opens it;
|
||||||
/// "…" offers editing and deleting. While its list is reordered, a handle to
|
/// "…" offers editing and deleting.
|
||||||
/// drag it by replaces "…", and a tap does nothing.
|
|
||||||
class const CardTile({
|
class const CardTile({
|
||||||
/// The card.
|
/// The card.
|
||||||
required final Card card,
|
required final Card card,
|
||||||
|
|
@ -41,20 +39,8 @@ class const CardTile({
|
||||||
|
|
||||||
/// Whether it is buried for the day.
|
/// Whether it is buried for the day.
|
||||||
final bool buried = false,
|
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,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -64,16 +50,16 @@ class const CardTile({
|
||||||
final hasMeta = tags.isNotEmpty;
|
final hasMeta = tags.isNotEmpty;
|
||||||
final hasBadges = report != null || buried;
|
final hasBadges = report != null || buried;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: _gap,
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
borderRadius: _radius,
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: dragIndex == null ? onOpen : null,
|
onTap: onOpen,
|
||||||
borderRadius: _radius,
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: _radius,
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
// At least this high, so rows are even, yet free to grow with
|
// 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?)
|
MenuAnchor(
|
||||||
ReorderableDragStartListener(
|
menuChildren: [
|
||||||
index: index,
|
MenuItemButton(
|
||||||
child: IconButton(
|
leadingIcon: const Icon(
|
||||||
tooltip: l10n.homeDragToReorder,
|
LucideIcons.pencil,
|
||||||
onPressed: () {},
|
size: 18,
|
||||||
icon: Icon(
|
|
||||||
LucideIcons.gripHorizontal,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
|
onPressed: onOpen,
|
||||||
|
child: Text(l10n.cardTileEdit),
|
||||||
),
|
),
|
||||||
)
|
if (report == null)
|
||||||
else
|
|
||||||
MenuAnchor(
|
|
||||||
menuChildren: [
|
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(
|
leadingIcon: const Icon(
|
||||||
LucideIcons.pencil,
|
LucideIcons.flag,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
onPressed: onOpen,
|
onPressed: onReport,
|
||||||
child: Text(l10n.cardTileEdit),
|
child: Text(l10n.cardReportTitle),
|
||||||
),
|
)
|
||||||
if (report == null)
|
else
|
||||||
MenuItemButton(
|
|
||||||
leadingIcon: const Icon(
|
|
||||||
LucideIcons.flag,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
onPressed: onReport,
|
|
||||||
child: Text(l10n.cardReportTitle),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
MenuItemButton(
|
|
||||||
leadingIcon: const Icon(
|
|
||||||
LucideIcons.flagOff,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
onPressed: onResolve,
|
|
||||||
child: Text(l10n.cardResolveReport),
|
|
||||||
),
|
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: Icon(
|
leadingIcon: const Icon(
|
||||||
buried ? LucideIcons.sunrise : LucideIcons.moon,
|
LucideIcons.flagOff,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
onPressed: onBuryToggle,
|
onPressed: onResolve,
|
||||||
child: Text(
|
child: Text(l10n.cardResolveReport),
|
||||||
buried ? l10n.cardUnbury : l10n.studyBuryCard,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: Icon(
|
leadingIcon: Icon(
|
||||||
LucideIcons.trash2,
|
buried ? LucideIcons.sunrise : LucideIcons.moon,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
onPressed: onDelete,
|
|
||||||
child: Text(
|
|
||||||
l10n.cardTileDelete,
|
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
onPressed: onBuryToggle,
|
||||||
leadingIcon: const Icon(LucideIcons.info, size: 18),
|
child: Text(
|
||||||
onPressed: onInfo,
|
buried ? l10n.cardUnbury : l10n.studyBuryCard,
|
||||||
child: Text(l10n.moreInfo),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
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(
|
onReorderItem: (from, to) => context.read<PathListBloc>().add(
|
||||||
PathListEventMoveRequested(paths[from].id, to),
|
PathListEventMoveRequested(paths[from].id, to),
|
||||||
),
|
),
|
||||||
proxyDecorator: liftWhileDragged(),
|
proxyDecorator: liftWhileDragged,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final path = paths[index];
|
final path = paths[index];
|
||||||
return ListTile(
|
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/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/data/anki_importer.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.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_list.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.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';
|
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.
|
/// Whether the paths show handles to drag them.
|
||||||
var _reorderingPaths = false;
|
var _reorderingPaths = false;
|
||||||
|
|
||||||
/// Whether the flashcards show handles to drag them.
|
|
||||||
var _reorderingCards = false;
|
|
||||||
|
|
||||||
/// Each tab's scrolling, to go back to its top.
|
/// Each tab's scrolling, to go back to its top.
|
||||||
final _scrolls = [
|
final _scrolls = [
|
||||||
for (final _ in StudysetTab.values) ScrollController(),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
@ -645,16 +630,9 @@ class _ContentState()
|
||||||
animation: _tabs,
|
animation: _tabs,
|
||||||
builder: (context, _) =>
|
builder: (context, _) =>
|
||||||
switch (StudysetTab.values[_tabs.index]) {
|
switch (StudysetTab.values[_tabs.index]) {
|
||||||
StudysetTab.flashcards => Padding(
|
StudysetTab.flashcards => const Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: EdgeInsets.only(right: 16),
|
||||||
child: CardListToolbar(
|
child: CardListToolbar(),
|
||||||
reordering: _reorderingCards,
|
|
||||||
// Below that, an icon alone.
|
|
||||||
labelled: constraints.maxWidth >= 480,
|
|
||||||
onReorderToggled: () => setState(
|
|
||||||
() => _reorderingCards = !_reorderingCards,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
StudysetTab.paths => _ReorderPaths(
|
StudysetTab.paths => _ReorderPaths(
|
||||||
reordering: _reorderingPaths,
|
reordering: _reorderingPaths,
|
||||||
|
|
@ -688,7 +666,6 @@ class _ContentState()
|
||||||
SliverCardList(
|
SliverCardList(
|
||||||
cards: PersonScope.of(context).cards,
|
cards: PersonScope.of(context).cards,
|
||||||
studysetId: widget.studysetId,
|
studysetId: widget.studysetId,
|
||||||
reordering: _reorderingCards,
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
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', () {
|
group('CardListBloc with tags', () {
|
||||||
|
|
|
||||||
|
|
@ -195,25 +195,6 @@ void main() {
|
||||||
expect(await repository.headsOf('card-9999'), isEmpty);
|
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 {
|
test('deleting blanks every revision before the card goes', () async {
|
||||||
final id = await repository.create('japanese', card('秘密', 'secret'));
|
final id = await repository.create('japanese', card('秘密', 'secret'));
|
||||||
await repository.save(
|
await repository.save(
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@ void main() {
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
"on desktop, a card's whole … opens its options, with no handle to "
|
"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 {
|
(tester) async {
|
||||||
await cards.create('japanese', card('猫', 'cat'));
|
await cards.create('japanese', card('猫', 'cat'));
|
||||||
await cards.create('japanese', card('犬', 'dog'));
|
await cards.create('japanese', card('犬', 'dog'));
|
||||||
|
|
@ -247,6 +247,7 @@ void main() {
|
||||||
|
|
||||||
expect(find.byIcon(Icons.drag_handle), findsNothing);
|
expect(find.byIcon(Icons.drag_handle), findsNothing);
|
||||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||||
|
expect(find.text('Reorder'), findsNothing);
|
||||||
await tester.tap(optionsOf('猫'));
|
await tester.tap(optionsOf('猫'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Edit'), findsOneWidget);
|
expect(find.text('Edit'), findsOneWidget);
|
||||||
|
|
@ -254,54 +255,6 @@ void main() {
|
||||||
variant: TargetPlatformVariant.only(TargetPlatform.linux),
|
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 '
|
testWidgets('builds only the cards in view, so a studyset of a thousand '
|
||||||
'opens as fast as one of ten', (tester) async {
|
'opens as fast as one of ten', (tester) async {
|
||||||
await cards.createAll('japanese', [
|
await cards.createAll('japanese', [
|
||||||
|
|
|
||||||
|
|
@ -129,18 +129,6 @@ class FakeCardGateway() implements CardGateway {
|
||||||
return revision.id;
|
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
|
@override
|
||||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
||||||
if (_cards[cardId] case final card?) {
|
if (_cards[cardId] case final card?) {
|
||||||
|
|
|
||||||
|
|
@ -131,30 +131,6 @@ void main() {
|
||||||
expect(resolved.content.back, text('a cat'));
|
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 '
|
test('ratings on two devices both count, and both devices compute the '
|
||||||
'same schedule', () async {
|
'same schedule', () async {
|
||||||
final phone = await device('phone');
|
final phone = await device('phone');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue