fix(app): Reorder among older items, export and text import that hold up

- Reorder: rows from before an order existed all share one position, and
  one moved among them landed at their end; the run it goes into now
  gets positions of its own (placeAmong), for notes, files, paths,
  steps and sub-studysets. Rows whose deletion can still be undone count
  for nothing, as the list leaves them out, and a dropped row takes its
  place at once instead of snapping back until the order is read again.
- Export: two studysets inside each other (moved so on two devices
  offline) no longer make it run forever; links to files whose names
  hold #, % or brackets work; PDFs, images and sounds go into the zip
  without being compressed again; any error says the export failed.
- Text import: a real CSV reader (quoted fields over several lines, a
  back over several quoted fields, a stray quote spoiling only its own
  line), the separator guessed from the text (semicolons from German
  spreadsheets), and files in UTF-16 or Windows-1252 read as meant.
- A tab whose notes, paths or files couldn't be read says so rather than
  "No … yet"; an Anki import from the Library that fails leaves no empty
  studyset behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 13:02:32 +02:00
commit 084af278b5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
30 changed files with 727 additions and 138 deletions

View file

@ -262,11 +262,11 @@
"studysetImportCards": "Karteikarten",
"studysetImportCardsHint": "Eingefügt oder aus einer CSV- oder TSV-Datei",
"cardTextImportTitle": "Karteikarten importieren",
"cardTextImportDescription": "Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen.",
"cardTextImportDescription": "Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen. Ein Feld in doppelten Anführungszeichen, wie Tabellenprogramme sie schreiben, darf Trennzeichen und Zeilenumbrüche enthalten.",
"cardTextImportHint": "Füge die Karten hier ein",
"cardTextImportLoadFile": "CSV- oder TSV-Datei laden",
"cardTextImportFileType": "CSV- oder TSV-Dateien",
"cardTextImportUnreadable": "Diese Datei ist kein Text in UTF-8.",
"cardTextImportUnreadable": "Diese Datei enthält keinen Text.",
"cardTextImportSeparator": "Trennzeichen",
"cardTextSeparatorTab": "Tab",
"cardTextSeparatorComma": "Komma",
@ -438,6 +438,9 @@
"syncMismatchMore": "Mehr",
"tagsReadFailed": "Die Tags konnten nicht gelesen werden.",
"notesReadFailed": "Die Notizen konnten nicht gelesen werden.",
"pathsReadFailed": "Die Lernpfade konnten nicht gelesen werden.",
"filesReadFailed": "Die Dateien konnten nicht gelesen werden.",
"tagPickerTitle": "Tags",
"tagPickerManage": "Tags verwalten",
"tagPickerSearchLabel": "Tag suchen oder erstellen",

View file

@ -290,11 +290,11 @@
"studysetImportCards": "Flashcards",
"studysetImportCardsHint": "Pasted, or from a CSV or TSV file",
"cardTextImportTitle": "Import flashcards",
"cardTextImportDescription": "One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.",
"cardTextImportDescription": "One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out. A field in double quotes, as spreadsheets write them, may hold separators and line breaks.",
"cardTextImportHint": "Paste the cards here",
"cardTextImportLoadFile": "Load a CSV or TSV file",
"cardTextImportFileType": "CSV or TSV files",
"cardTextImportUnreadable": "This file isn't text in UTF-8.",
"cardTextImportUnreadable": "This file doesn't hold text.",
"cardTextImportSeparator": "Separator",
"cardTextSeparatorTab": "Tab",
"cardTextSeparatorComma": "Comma",
@ -475,6 +475,9 @@
"syncMismatchMore": "More",
"tagsReadFailed": "Couldn't read the tags.",
"notesReadFailed": "Couldn't read the notes.",
"pathsReadFailed": "Couldn't read the learning paths.",
"filesReadFailed": "Couldn't read the files.",
"tagPickerTitle": "Tags",
"tagPickerManage": "Manage tags",
"tagPickerSearchLabel": "Search or create a tag",

View file

@ -1633,7 +1633,7 @@ abstract class AppLocalizations {
/// No description provided for @cardTextImportDescription.
///
/// In en, this message translates to:
/// **'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.'**
/// **'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out. A field in double quotes, as spreadsheets write them, may hold separators and line breaks.'**
String get cardTextImportDescription;
/// No description provided for @cardTextImportHint.
@ -1657,7 +1657,7 @@ abstract class AppLocalizations {
/// No description provided for @cardTextImportUnreadable.
///
/// In en, this message translates to:
/// **'This file isn\'t text in UTF-8.'**
/// **'This file doesn\'t hold text.'**
String get cardTextImportUnreadable;
/// No description provided for @cardTextImportSeparator.
@ -2650,6 +2650,24 @@ abstract class AppLocalizations {
/// **'Couldn\'t read the tags.'**
String get tagsReadFailed;
/// No description provided for @notesReadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read the notes.'**
String get notesReadFailed;
/// No description provided for @pathsReadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read the learning paths.'**
String get pathsReadFailed;
/// No description provided for @filesReadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read the files.'**
String get filesReadFailed;
/// No description provided for @tagPickerTitle.
///
/// In en, this message translates to:

View file

@ -970,7 +970,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cardTextImportDescription =>
'Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen.';
'Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen. Ein Feld in doppelten Anführungszeichen, wie Tabellenprogramme sie schreiben, darf Trennzeichen und Zeilenumbrüche enthalten.';
@override
String get cardTextImportHint => 'Füge die Karten hier ein';
@ -982,7 +982,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get cardTextImportFileType => 'CSV- oder TSV-Dateien';
@override
String get cardTextImportUnreadable => 'Diese Datei ist kein Text in UTF-8.';
String get cardTextImportUnreadable => 'Diese Datei enthält keinen Text.';
@override
String get cardTextImportSeparator => 'Trennzeichen';
@ -1552,6 +1552,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get tagsReadFailed => 'Die Tags konnten nicht gelesen werden.';
@override
String get notesReadFailed => 'Die Notizen konnten nicht gelesen werden.';
@override
String get pathsReadFailed => 'Die Lernpfade konnten nicht gelesen werden.';
@override
String get filesReadFailed => 'Die Dateien konnten nicht gelesen werden.';
@override
String get tagPickerTitle => 'Tags';

View file

@ -966,7 +966,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cardTextImportDescription =>
'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.';
'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out. A field in double quotes, as spreadsheets write them, may hold separators and line breaks.';
@override
String get cardTextImportHint => 'Paste the cards here';
@ -978,7 +978,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get cardTextImportFileType => 'CSV or TSV files';
@override
String get cardTextImportUnreadable => 'This file isn\'t text in UTF-8.';
String get cardTextImportUnreadable => 'This file doesn\'t hold text.';
@override
String get cardTextImportSeparator => 'Separator';
@ -1542,6 +1542,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tagsReadFailed => 'Couldn\'t read the tags.';
@override
String get notesReadFailed => 'Couldn\'t read the notes.';
@override
String get pathsReadFailed => 'Couldn\'t read the learning paths.';
@override
String get filesReadFailed => 'Couldn\'t read the files.';
@override
String get tagPickerTitle => 'Tags';

View file

@ -28,3 +28,55 @@ String positionAmong(List<String> others, int index) {
}
return FractionalIndexing.generateKeyBetween(lower, upper);
}
/// Where an item moved to [index] among [others] (the other items in their
/// order, with their ids) goes: its new position under [movedId], and new
/// positions for others where it needs them. Items that share a position
/// (all those from before an order existed stand at `a0`) leave no room
/// between them, so a run of them around the place gets positions of its
/// own first, in the order shown, the moved item among them.
Map<String, String> placeAmong(
List<({String id, String position})> others,
int index,
String movedId,
) {
final at = index.clamp(0, others.length);
final lower = at > 0 ? others[at - 1].position : null;
final next = at < others.length ? others[at].position : null;
if (lower == null || next == null || lower != next) {
return {
movedId: positionAmong([for (final other in others) other.position], at),
};
}
var start = at - 1;
while (start > 0 && others[start - 1].position == lower) {
start--;
}
var end = at;
while (end < others.length && others[end].position == lower) {
end++;
}
final keys = FractionalIndexing.generateNKeysBetween(
start > 0 ? others[start - 1].position : null,
end < others.length ? others[end].position : null,
end - start + 1,
);
return {
for (var i = start; i < at; i++) others[i].id: keys[i - start],
movedId: keys[at - start],
for (var i = at; i < end; i++) others[i].id: keys[i - start + 1],
};
}
/// [items] with the one [isMoved] picks at [toIndex] among the others, as
/// a list shows a row dropped there before its new position is stored;
/// as they are when none is picked.
List<T> movedTo<T>(List<T> items, bool Function(T item) isMoved, int toIndex) {
final from = items.indexWhere(isMoved);
if (from < 0) {
return items;
}
final moved = [...items];
final item = moved.removeAt(from);
return moved..insert(toIndex.clamp(0, moved.length), item);
}

View file

@ -87,15 +87,16 @@ class AnkiImportBloc(
if (state case final AnkiImportStateReady ready when package != null) {
final options = ready.options;
emit(AnkiImportStateRunning(ready.summary, options, 0));
String? made;
try {
final result = await _importer.import(
package,
studysetId:
_studysetId ??
await _importer.newStudysetFor(
(made = await _importer.newStudysetFor(
package,
fallbackTitle: _fileTitle,
),
)),
mode: options.mode,
keepProgress: options.keepProgress && ready.summary.hasReviews,
includeMedia: options.includeMedia,
@ -106,6 +107,13 @@ class AnkiImportBloc(
emit(AnkiImportStateDone(result, options.mode));
} on Exception catch (error) {
log('Importing an Anki package failed', name: 'Anki', error: error);
if (made != null) {
try {
await _importer.discardIfEmpty(made);
} on Object catch (error) {
log('Discarding its studyset failed', name: 'Anki', error: error);
}
}
emit(const AnkiImportStateFailed(null));
}
}

View file

@ -69,6 +69,15 @@ class AnkiImporter(
);
}
/// Takes away the studyset [studysetId] made for an import that failed,
/// if nothing came into it, so a failure leaves no empty studyset behind.
/// One that holds cards stays: what was imported is there to see.
Future<void> discardIfEmpty(String studysetId) async {
if ((await _cards.cardsOf(studysetId)).isEmpty) {
await _studysets.delete(studysetId);
}
}
/// Imports [package] into the studyset [studysetId] as [mode] says, its
/// review history if [keepProgress], its files if [includeMedia];
/// [onProgress] hears how far it got, from 0 to 1.

View file

@ -16,62 +16,160 @@ enum CardSeparator(final String char) {
/// A card's front and back, read from a line of text.
typedef CardLine = ({String front, String back});
/// The cards in [text]: one per line, split at the line's first [separator],
/// so the back may hold the separator too. A field in double quotes, as
/// spreadsheets write one holding the separator, is read without them (a
/// doubled quote inside standing for one). Blank lines, lines without the
/// separator and lines with an empty side (a header without a back, say)
/// are left out.
/// The cards in [text]: one per record, as spreadsheets write them: a line,
/// unless a field in double quotes runs over several (a doubled quote
/// inside stands for one). A record's fields are split at [separator]
/// outside quotes; the first is the front, the rest the back, joined by the
/// separator again, so a back may hold one too. Blank records, those
/// without a second field and those with an empty side (a header without a
/// back, say) are left out, and so is a line whose quote never closes.
List<CardLine> parseCardLines(String text, CardSeparator separator) => [
for (final line in const LineSplitter().convert(
// Spreadsheets put a byte order mark before UTF-8 text.
text.startsWith('') ? text.substring(1) : text,
))
if (_split(line, separator.char) case final card?
when card.front.isNotEmpty && card.back.isNotEmpty)
card,
for (final fields in _records(_withoutBom(text), separator.char))
if (fields.length >= 2)
if ((
front: fields.first.trim(),
back: fields.skip(1).join(separator.char).trim(),
)
case final card when card.front.isNotEmpty && card.back.isNotEmpty)
card,
];
CardLine? _split(String line, String separator) {
final start = line.length - line.trimLeft().length;
if (!line.startsWith('"', start)) {
final at = line.indexOf(separator);
return at < 0
? null
: (
front: line.substring(0, at).trim(),
back: _unquoted(line.substring(at + separator.length).trim()),
);
}
// A quoted front: up to its closing quote, then the separator.
final front = StringBuffer();
var i = start + 1;
while (true) {
if (i >= line.length) {
return null;
}
if (line[i] != '"') {
front.write(line[i++]);
} else if (line.startsWith('""', i)) {
front.write('"');
i += 2;
} else {
i++;
break;
/// The separator [text] uses, by what most of its first lines hold outside
/// quotes: tabs, semicolons (what spreadsheets write where a comma is the
/// decimal mark, in German say) or commas; by [fileName] when they hold
/// none ([CardSeparator.forFile]).
CardSeparator guessSeparator(String text, {required String fileName}) {
final lines = [
for (final line in const LineSplitter().convert(_withoutBom(text)))
if (line.trim().isNotEmpty) line.replaceAll(RegExp('"[^"]*"'), ''),
].take(20);
var best = CardSeparator.forFile(fileName);
var bestCount = 0;
for (final separator in CardSeparator.values) {
final count = lines.where((line) => line.contains(separator.char)).length;
if (count > bestCount) {
best = separator;
bestCount = count;
}
}
final at = line.indexOf(separator, i);
if (at < 0 || line.substring(i, at).trim().isNotEmpty) {
return null;
}
return (
front: front.toString().trim(),
back: _unquoted(line.substring(at + separator.length).trim()),
);
return best;
}
/// [field] without the double quotes around it, if it has them.
String _unquoted(String field) =>
field.length >= 2 && field.startsWith('"') && field.endsWith('"')
? field.substring(1, field.length - 1).replaceAll('""', '"')
: field;
/// The text in [bytes] as a spreadsheet or text editor saved it: UTF-8, or
/// UTF-16 with its byte order mark (what Excel saves as "Unicode text"), or
/// else Windows-1252 (what older Excel saves as CSV in western Europe).
String decodeCardText(List<int> bytes) {
if (bytes.length >= 2) {
final (first, second) = (bytes[0], bytes[1]);
final littleEndian = first == 0xFF && second == 0xFE;
if (littleEndian || (first == 0xFE && second == 0xFF)) {
return String.fromCharCodes([
for (var i = 2; i + 1 < bytes.length; i += 2)
if (littleEndian)
bytes[i] | bytes[i + 1] << 8
else
bytes[i] << 8 | bytes[i + 1],
]);
}
}
try {
return utf8.decode(bytes);
} on FormatException {
return String.fromCharCodes([
for (final byte in bytes) _windows1252[byte] ?? byte,
]);
}
}
/// Where Windows-1252 differs from Latin-1: 0x80 to 0x9F hold signs (€,
/// curly quotes, dashes) rather than control characters.
const _windows1252 = {
0x80: 0x20AC,
0x82: 0x201A,
0x83: 0x0192,
0x84: 0x201E,
0x85: 0x2026,
0x86: 0x2020,
0x87: 0x2021,
0x88: 0x02C6,
0x89: 0x2030,
0x8A: 0x0160,
0x8B: 0x2039,
0x8C: 0x0152,
0x8E: 0x017D,
0x91: 0x2018,
0x92: 0x2019,
0x93: 0x201C,
0x94: 0x201D,
0x95: 0x2022,
0x96: 0x2013,
0x97: 0x2014,
0x98: 0x02DC,
0x99: 0x2122,
0x9A: 0x0161,
0x9B: 0x203A,
0x9C: 0x0153,
0x9E: 0x017E,
0x9F: 0x0178,
};
/// [text] without the byte order mark spreadsheets put before UTF-8 text.
String _withoutBom(String text) =>
text.startsWith('\uFEFF') ? text.substring(1) : text;
/// The records of [text], each its fields: split at [separator] outside
/// double quotes, a record ending at a line break outside them. A quote
/// that never closes spoils only its own line: reading goes on after it.
List<List<String>> _records(String text, String separator) {
final records = <List<String>>[];
var i = 0;
while (i < text.length) {
final start = i;
final fields = <String>[];
var field = StringBuffer();
var quoted = false;
var unclosed = false;
while (true) {
if (i >= text.length) {
unclosed = quoted;
break;
}
final char = text[i];
if (quoted) {
if (char == '"' && text.startsWith('""', i)) {
field.write('"');
i += 2;
} else if (char == '"') {
quoted = false;
i++;
} else {
field.write(char);
i++;
}
} else if (char == '"' && field.toString().trim().isEmpty) {
// A quoted field, spaces before it left out.
field = StringBuffer();
quoted = true;
i++;
} else if (text.startsWith(separator, i)) {
fields.add(field.toString());
field = StringBuffer();
i += separator.length;
} else if (char == '\n' || char == '\r') {
i += char == '\r' && text.startsWith('\r\n', i) ? 2 : 1;
break;
} else {
field.write(char);
i++;
}
}
if (unclosed) {
final end = text.indexOf('\n', start);
i = end < 0 ? text.length : end + 1;
continue;
}
fields.add(field.toString());
records.add(fields);
}
return records;
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -53,8 +52,9 @@ class _CardTextSheetState() extends State<_CardTextSheet> {
super.dispose();
}
/// Reads a CSV or TSV file into the field, its separator chosen by its
/// name (the person may still change it).
/// Reads a CSV or TSV file into the field, in whichever encoding it was
/// saved, its separator guessed from what it holds (the person may still
/// change it).
Future<void> _load() async {
final l10n = AppLocalizations.of(context);
final file = await DependenciesScope.of(context).files.pick(
@ -64,16 +64,12 @@ class _CardTextSheetState() extends State<_CardTextSheet> {
if (file == null || !mounted) {
return;
}
try {
final text = utf8.decode(file.bytes);
setState(() {
_text.text = text;
_separator = CardSeparator.forFile(file.name);
_unreadable = false;
});
} on FormatException {
setState(() => _unreadable = true);
}
final text = decodeCardText(file.bytes);
setState(() {
_text.text = text;
_separator = guessSeparator(text, fileName: file.name);
_unreadable = text.contains('\u0000');
});
}
@override

View file

@ -83,7 +83,7 @@ class StudysetMarkdownExporter(
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
}
final export = _Export(texts, style, _fileOf);
await _studyset(export, root, all, depth: 1);
await _studyset(export, root, all, depth: 1, seen: {});
return (
markdown: '${export.blocks.join('\n\n')}\n',
files: export.files,
@ -91,12 +91,19 @@ class StudysetMarkdownExporter(
);
}
/// [studyset] and everything inside it, once each: two studysets inside
/// each other (moved so on two devices offline) end the walk, as [seen]
/// keeps those written.
Future<void> _studyset(
_Export out,
Studyset studyset,
List<Studyset> all, {
required int depth,
required Set<String> seen,
}) async {
if (!seen.add(studyset.id)) {
return;
}
final content = studyset.content;
out.heading(depth, content.title);
if (content.secondaryTitle case final secondary?
@ -184,7 +191,7 @@ class StudysetMarkdownExporter(
}
for (final child in childrenOf(studyset.id, all)) {
await _studyset(out, child, all, depth: depth + 1);
await _studyset(out, child, all, depth: depth + 1, seen: seen);
}
}
@ -431,7 +438,7 @@ class _Export(
'.${_extensionOf(mimeType)}',
);
files.add((path: path, bytes: file.bytes));
return _links[assetId] = path;
return _links[assetId] = _linkTo(path);
}
/// [name] in [folder], numbered (`-2`, `-3`…) where a file has it already.
@ -448,6 +455,15 @@ class _Export(
}
}
/// [path] as a Markdown link names it: the characters that would end the
/// link or start a fragment there (`#`, `%`, brackets, …) percent-encoded,
/// the rest (Japanese, say) as it is.
String _linkTo(String path) => path.replaceAllMapped(
RegExp(r'[%#?()\[\]<> ]'),
(match) =>
'%${match[0]!.codeUnitAt(0).toRadixString(16).toUpperCase().padLeft(2, '0')}',
);
/// [name] without what file systems refuse in a file's name, and spaces as
/// underscores; null for none left.
String? _safe(String? name) {

View file

@ -41,7 +41,7 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
texts: exportTexts(l10n),
style: style,
);
} on Exception catch (_) {
} on Object catch (_) {
say(l10n.exportFailed);
return;
}
@ -58,7 +58,14 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
} else {
final archive = Archive()..add(ArchiveFile.bytes('$name.md', markdown));
for (final file in exported.files) {
archive.add(ArchiveFile.bytes(file.path, file.bytes));
archive.add(
ArchiveFile.bytes(file.path, file.bytes)
// PDFs, images and sounds are compressed already: deflating
// them again takes long and saves nothing.
..compression = _compressed.hasMatch(file.path)
? CompressionType.none
: null,
);
}
saved = await saver.save(
name: '$name.zip',
@ -66,7 +73,7 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
mimeType: 'application/zip',
);
}
} on Exception catch (_) {
} on Object catch (_) {
say(l10n.exportFailed);
return;
}
@ -121,3 +128,9 @@ String _fileStem(String title) {
.replaceAll(RegExp(r'\s+'), ' ');
return stem.isEmpty || stem.startsWith('.') ? 'studyset' : stem;
}
/// Files whose content is compressed already, by their extension.
final _compressed = RegExp(
r'\.(pdf|png|jpe?g|gif|webp|mp3|m4a|aac|ogg|opus|mp4|webm|zip)$',
caseSensitive: false,
);

View file

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
@ -52,10 +53,24 @@ class PathEditorBloc(
);
emit(state.copyWith(added: created.stepId));
});
on<PathEditorEventStepMoved>(
(event, emit) =>
_paths.moveStep(_pathId, event.stepId, toIndex: event.toIndex),
);
on<PathEditorEventStepMoved>((event, emit) {
// The list takes the new order at once; the stored one follows.
emit(
state.copyWith(
steps: movedTo(
state.steps,
(step) => step.id == event.stepId,
event.toIndex,
),
),
);
return _paths.moveStep(
_pathId,
event.stepId,
toIndex: event.toIndex,
hidden: _pending,
);
});
}
List<LearningPathStep>? _steps;

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
@ -57,10 +58,24 @@ class PathListBloc(
);
}
});
on<PathListEventMoveRequested>(
(event, emit) =>
_paths.movePath(_studysetId, event.id, toIndex: event.toIndex),
);
on<PathListEventMoveRequested>((event, emit) {
// The list takes the new order at once; the stored one follows.
emit(
state.copyWith(
paths: movedTo(
state.paths,
(path) => path.id == event.id,
event.toIndex,
),
),
);
return _paths.movePath(
_studysetId,
event.id,
toIndex: event.toIndex,
hidden: _pending,
);
});
on<PathListEventResetRequested>((event, emit) async {
final stepIds = {
for (final step in await _paths.stepsOf(event.id)) step.id,

View file

@ -77,20 +77,27 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
_changes.add(null);
}
/// Moves the path [id] to [toIndex] among the paths of [studysetId].
/// Moves the path [id] to [toIndex] among the paths of [studysetId] the
/// list shows: those [hidden] (their deletion can still be undone) left
/// out, as the list leaves them out.
Future<void> movePath(
String studysetId,
String id, {
required int toIndex,
Set<String> hidden = const {},
}) async {
final others = [
for (final path in await pathsOf(studysetId))
if (path.id != id) (id: path.id, position: path.position),
if (path.id != id && !hidden.contains(path.id))
(id: path.id, position: path.position),
];
await _gateway.updatePathPosition(
for (final MapEntry(key: moved, value: position) in placeAmong(
others,
toIndex,
id,
positionAmong([for (final other in others) other.position], toIndex),
);
).entries) {
await _gateway.updatePathPosition(moved, position);
}
_changes.add(null);
}
@ -181,20 +188,27 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
return id;
}
/// Moves the step [id] to [toIndex] among the steps of [pathId].
/// Moves the step [id] to [toIndex] among the steps of [pathId] the
/// editor shows: those [hidden] (their deletion can still be undone) left
/// out, as it leaves them out.
Future<void> moveStep(
String pathId,
String id, {
required int toIndex,
Set<String> hidden = const {},
}) async {
final others = [
for (final row in await _gateway.steps({pathId}))
if (row.id != id) (id: row.id, position: row.position),
if (row.id != id && !hidden.contains(row.id))
(id: row.id, position: row.position),
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
await _gateway.updateStepPosition(
for (final MapEntry(key: moved, value: position) in placeAmong(
others,
toIndex,
id,
positionAmong([for (final other in others) other.position], toIndex),
);
).entries) {
await _gateway.updateStepPosition(moved, position);
}
_changes.add(null);
}

View file

@ -44,6 +44,13 @@ class const SliverPathList({
child: const AppLoadingIndicator(),
);
}
if (state.failed && state.paths.isEmpty) {
// Read once, they show; nothing to add until then.
return SliverCentered(
padding: padding,
child: Text(l10n.pathsReadFailed),
);
}
if (state.paths.isEmpty) {
return SliverCentered(
padding: padding,

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
@ -16,10 +17,24 @@ class NoteListBloc(
) extends Bloc<NoteListEvent, NoteListState> {
this : super(const NoteListState()) {
on<NoteListEventStarted>(_onStarted);
on<NoteListEventMoveRequested>(
(event, emit) =>
_notes.move(_studysetId, event.id, toIndex: event.toIndex),
);
on<NoteListEventMoveRequested>((event, emit) {
// The list takes the new order at once; the stored one follows.
emit(
state.copyWith(
notes: movedTo(
state.notes,
(note) => note.id == event.id,
event.toIndex,
),
),
);
return _notes.move(
_studysetId,
event.id,
toIndex: event.toIndex,
hidden: _pending,
);
});
}
/// The notes as last read, and whether they were read at all.

View file

@ -118,17 +118,27 @@ class NoteRepository(final NoteGateway _gateway) {
return ids;
}
/// Moves the note [noteId] to [toIndex] among the notes of [studysetId].
/// Moves the note [noteId] to [toIndex] among the notes of [studysetId]
/// the list shows: those [hidden] (their deletion can still be undone)
/// left out, as the list leaves them out.
Future<void> move(
String studysetId,
String noteId, {
required int toIndex,
Set<String> hidden = const {},
}) async {
final others = [
for (final note in await notesOf(studysetId))
if (note.id != noteId) note.position,
if (note.id != noteId && !hidden.contains(note.id))
(id: note.id, position: note.position),
];
await _gateway.updatePosition(noteId, positionAmong(others, toIndex));
for (final MapEntry(key: id, value: position) in placeAmong(
others,
toIndex,
noteId,
).entries) {
await _gateway.updatePosition(id, position);
}
_changes.add(null);
}

View file

@ -44,6 +44,13 @@ class const SliverNoteList({
child: const AppLoadingIndicator(),
);
}
if (state.failed && state.notes.isEmpty) {
// Read once, they show; nothing to add until then.
return SliverCentered(
padding: padding,
child: Text(l10n.notesReadFailed),
);
}
if (state.notes.isEmpty) {
return SliverCentered(
padding: padding,

View file

@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
@ -69,10 +70,24 @@ class PdfListBloc(
await _import(emit, bytes, fileName, true);
}
});
on<PdfListEventMoveRequested>(
(event, emit) =>
_pdfs.move(_studysetId, event.id, toIndex: event.toIndex),
);
on<PdfListEventMoveRequested>((event, emit) {
// The list takes the new order at once; the stored one follows.
emit(
state.copyWith(
rows: movedTo(
state.rows,
(row) => row.source.id == event.id,
event.toIndex,
),
),
);
return _pdfs.move(
_studysetId,
event.id,
toIndex: event.toIndex,
hidden: _pending,
);
});
on<PdfListEventRenamed>(
(event, emit) => _pdfs.rename(event.pdfId, event.title),
);

Some files were not shown because too many files have changed in this diff Show more