Fix what the first Android run found
The flow ran on a Pixel 9a and its emulator (2026-10-02); what it found: - The composer stayed on the card just written after "New flashcard", and on the original after "Duplicate": leaving a card changed the state before the new one was in it, so the screen took the new card for gone. It now waits for the card it asked for. - Bottom sheets reached under the status bar with the keyboard open (useSafeArea). - The field being written stayed behind the keyboard: the editor scrolls only within itself, so the card face now keeps it in view when it opens and when the keyboard rises, as a text field does. - A tap on read-only rich text put a caret in it and opened the keyboard on phones, and in the composer never opened the field. RichContentView is display only now: no selection, taps go to what surrounds it, a sound's button still plays (decided 2026-10-02). - The card types broke their names on a phone; they are choice chips that wrap (decided 2026-10-02). - The week overview marked days before the person's first activity as missed; they show nothing now. An import starts the activity too, without counting as studying (decided 2026-10-02). - A composer preview cut short gave no sign of it; it fades out over its last 32 dp, only when cut short (decided 2026-10-02). - "Isn't on this device yet" showed for any image or sound that failed; a file that is here but can't be decoded now says so, and offers no retry. - The signed-in account card was narrower than the anonymous one. - The composer's title said "Create flashcards" also when opened on a stored card; it says "Edit flashcards in …" then (decided 2026-10-02). - The Anki fixtures carry a valid PNG and a silent MP3, so they also serve to try the import by hand (decided 2026-10-02); regenerated with Anki 26.9.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
79bc2fc618
commit
079179d2ed
29 changed files with 552 additions and 73 deletions
|
|
@ -172,6 +172,9 @@
|
||||||
"composerTitle": "Create flashcards",
|
"composerTitle": "Create flashcards",
|
||||||
"composerTitleFor": "Create flashcards for \"{title}\"",
|
"composerTitleFor": "Create flashcards for \"{title}\"",
|
||||||
"@composerTitleFor": {"placeholders": {"title": {"type": "String"}}},
|
"@composerTitleFor": {"placeholders": {"title": {"type": "String"}}},
|
||||||
|
"composerEditTitle": "Edit flashcards",
|
||||||
|
"composerEditTitleFor": "Edit flashcards in \"{title}\"",
|
||||||
|
"@composerEditTitleFor": {"placeholders": {"title": {"type": "String"}}},
|
||||||
"composerSaving": "Saving…",
|
"composerSaving": "Saving…",
|
||||||
"composerSaveFailed": "Save failed — retry",
|
"composerSaveFailed": "Save failed — retry",
|
||||||
"composerAllSaved": "All changes saved",
|
"composerAllSaved": "All changes saved",
|
||||||
|
|
@ -376,10 +379,12 @@
|
||||||
"fileImage": "Image",
|
"fileImage": "Image",
|
||||||
"fileSound": "Sound",
|
"fileSound": "Sound",
|
||||||
"fileImageUnavailable": "This image isn't on this device yet. It shows once it's uploaded and you're online.",
|
"fileImageUnavailable": "This image isn't on this device yet. It shows once it's uploaded and you're online.",
|
||||||
|
"fileImageUnreadable": "This image can't be shown. Its file may be damaged, or in a format this device can't read.",
|
||||||
"fileFromElsewhere": "Files from the web aren't shown.",
|
"fileFromElsewhere": "Files from the web aren't shown.",
|
||||||
"filePlaySound": "Play sound",
|
"filePlaySound": "Play sound",
|
||||||
"fileStopSound": "Stop sound",
|
"fileStopSound": "Stop sound",
|
||||||
"fileSoundUnavailable": "This sound isn't on this device yet. It plays once it's uploaded and you're online.",
|
"fileSoundUnavailable": "This sound isn't on this device yet. It plays once it's uploaded and you're online.",
|
||||||
|
"fileSoundUnplayable": "This sound can't be played. Its file may be damaged, or in a format this device can't play.",
|
||||||
"ankiImportMenu": "Import from Anki",
|
"ankiImportMenu": "Import from Anki",
|
||||||
"ankiImportFileType": "Anki decks",
|
"ankiImportFileType": "Anki decks",
|
||||||
"ankiImportTitle": "Import Anki deck",
|
"ankiImportTitle": "Import Anki deck",
|
||||||
|
|
|
||||||
|
|
@ -970,6 +970,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Create flashcards for \"{title}\"'**
|
/// **'Create flashcards for \"{title}\"'**
|
||||||
String composerTitleFor(String title);
|
String composerTitleFor(String title);
|
||||||
|
|
||||||
|
/// No description provided for @composerEditTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit flashcards'**
|
||||||
|
String get composerEditTitle;
|
||||||
|
|
||||||
|
/// No description provided for @composerEditTitleFor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit flashcards in \"{title}\"'**
|
||||||
|
String composerEditTitleFor(String title);
|
||||||
|
|
||||||
/// No description provided for @composerSaving.
|
/// No description provided for @composerSaving.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2032,6 +2044,12 @@ abstract class AppLocalizations {
|
||||||
/// **'This image isn\'t on this device yet. It shows once it\'s uploaded and you\'re online.'**
|
/// **'This image isn\'t on this device yet. It shows once it\'s uploaded and you\'re online.'**
|
||||||
String get fileImageUnavailable;
|
String get fileImageUnavailable;
|
||||||
|
|
||||||
|
/// No description provided for @fileImageUnreadable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This image can\'t be shown. Its file may be damaged, or in a format this device can\'t read.'**
|
||||||
|
String get fileImageUnreadable;
|
||||||
|
|
||||||
/// No description provided for @fileFromElsewhere.
|
/// No description provided for @fileFromElsewhere.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2056,6 +2074,12 @@ abstract class AppLocalizations {
|
||||||
/// **'This sound isn\'t on this device yet. It plays once it\'s uploaded and you\'re online.'**
|
/// **'This sound isn\'t on this device yet. It plays once it\'s uploaded and you\'re online.'**
|
||||||
String get fileSoundUnavailable;
|
String get fileSoundUnavailable;
|
||||||
|
|
||||||
|
/// No description provided for @fileSoundUnplayable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.'**
|
||||||
|
String get fileSoundUnplayable;
|
||||||
|
|
||||||
/// No description provided for @ankiImportMenu.
|
/// No description provided for @ankiImportMenu.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Create flashcards for \"$title\"';
|
return 'Create flashcards for \"$title\"';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerEditTitle => 'Edit flashcards';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String composerEditTitleFor(String title) {
|
||||||
|
return 'Edit flashcards in \"$title\"';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerSaving => 'Saving…';
|
String get composerSaving => 'Saving…';
|
||||||
|
|
||||||
|
|
@ -1179,6 +1187,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get fileImageUnavailable =>
|
String get fileImageUnavailable =>
|
||||||
'This image isn\'t on this device yet. It shows once it\'s uploaded and you\'re online.';
|
'This image isn\'t on this device yet. It shows once it\'s uploaded and you\'re online.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileImageUnreadable =>
|
||||||
|
'This image can\'t be shown. Its file may be damaged, or in a format this device can\'t read.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fileFromElsewhere => 'Files from the web aren\'t shown.';
|
String get fileFromElsewhere => 'Files from the web aren\'t shown.';
|
||||||
|
|
||||||
|
|
@ -1192,6 +1204,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get fileSoundUnavailable =>
|
String get fileSoundUnavailable =>
|
||||||
'This sound isn\'t on this device yet. It plays once it\'s uploaded and you\'re online.';
|
'This sound isn\'t on this device yet. It plays once it\'s uploaded and you\'re online.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fileSoundUnplayable =>
|
||||||
|
'This sound can\'t be played. Its file may be damaged, or in a format this device can\'t play.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ankiImportMenu => 'Import from Anki';
|
String get ankiImportMenu => 'Import from Anki';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,11 @@ class SoundBloc(
|
||||||
await _player.stop();
|
await _player.stop();
|
||||||
case SoundState.loading:
|
case SoundState.loading:
|
||||||
break;
|
break;
|
||||||
case SoundState.idle || SoundState.unavailable:
|
case SoundState.idle || SoundState.missing || SoundState.unplayable:
|
||||||
emit(SoundState.loading);
|
emit(SoundState.loading);
|
||||||
final file = await _assets.fileOf(_assetId);
|
final file = await _assets.fileOf(_assetId);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
emit(SoundState.unavailable);
|
emit(SoundState.missing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_path = file.path;
|
_path = file.path;
|
||||||
|
|
@ -55,7 +55,7 @@ class SoundBloc(
|
||||||
await _player.play(file.path, mimeType: file.mimeType);
|
await _player.play(file.path, mimeType: file.mimeType);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
log('Playing a sound failed', name: 'Sound', error: error);
|
log('Playing a sound failed', name: 'Sound', error: error);
|
||||||
emit(SoundState.unavailable);
|
emit(SoundState.unplayable);
|
||||||
}
|
}
|
||||||
// Playing shows once the player says so.
|
// Playing shows once the player says so.
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +100,10 @@ enum SoundState() {
|
||||||
/// Playing.
|
/// Playing.
|
||||||
playing,
|
playing,
|
||||||
|
|
||||||
/// Its file can't be had now (not uploaded yet, or no connection), or
|
/// Its file can't be had now: not uploaded yet, or no connection.
|
||||||
/// didn't play.
|
missing,
|
||||||
unavailable,
|
|
||||||
|
/// Its file is here but didn't play: damaged, or in a format the
|
||||||
|
/// platform can't decode.
|
||||||
|
unplayable,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class const StoredFileImage(
|
||||||
final file = await assets.fileOf(assetId);
|
final file = await assets.fileOf(assetId);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key));
|
scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key));
|
||||||
throw StateError('The file $assetId is not available.');
|
throw FileNotHereException(assetId);
|
||||||
}
|
}
|
||||||
return await decode(await ImmutableBuffer.fromUint8List(file.bytes));
|
return await decode(await ImmutableBuffer.fromUint8List(file.bytes));
|
||||||
}
|
}
|
||||||
|
|
@ -47,3 +47,13 @@ class const StoredFileImage(
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(identityHashCode(assets), assetId);
|
int get hashCode => Object.hash(identityHashCode(assets), assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The file [assetId] is neither on this device nor to be had from the
|
||||||
|
/// server now: not uploaded yet, or no connection.
|
||||||
|
final class const FileNotHereException(
|
||||||
|
/// The file's asset row.
|
||||||
|
final String assetId,
|
||||||
|
) implements Exception {
|
||||||
|
@override
|
||||||
|
String toString() => 'The file $assetId is not available.';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,9 @@ class const _FileImage({
|
||||||
semanticLabel: AppLocalizations.of(context).fileImage,
|
semanticLabel: AppLocalizations.of(context).fileImage,
|
||||||
errorBuilder: (context, error, stackTrace) => _Unavailable(
|
errorBuilder: (context, error, stackTrace) => _Unavailable(
|
||||||
icon: LucideIcons.imageOff,
|
icon: LucideIcons.imageOff,
|
||||||
text: AppLocalizations.of(context).fileImageUnavailable,
|
text: error is FileNotHereException
|
||||||
|
? AppLocalizations.of(context).fileImageUnavailable
|
||||||
|
: AppLocalizations.of(context).fileImageUnreadable,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -233,12 +235,22 @@ class const _SoundButton() extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final state = context.watch<SoundBloc>().state;
|
final state = context.watch<SoundBloc>().state;
|
||||||
if (state == SoundState.unavailable) {
|
switch (state) {
|
||||||
return _Unavailable(
|
case SoundState.missing:
|
||||||
icon: LucideIcons.volumeOff,
|
return _Unavailable(
|
||||||
text: l10n.fileSoundUnavailable,
|
icon: LucideIcons.volumeOff,
|
||||||
onRetry: () => context.read<SoundBloc>().add(const SoundEventToggled()),
|
text: l10n.fileSoundUnavailable,
|
||||||
);
|
onRetry: () =>
|
||||||
|
context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||||
|
);
|
||||||
|
// Trying again wouldn't change the file.
|
||||||
|
case SoundState.unplayable:
|
||||||
|
return _Unavailable(
|
||||||
|
icon: LucideIcons.volumeOff,
|
||||||
|
text: l10n.fileSoundUnplayable,
|
||||||
|
);
|
||||||
|
case SoundState.idle || SoundState.loading || SoundState.playing:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return Align(
|
return Align(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
|
@ -251,7 +263,9 @@ class const _SoundButton() extends StatelessWidget {
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
SoundState.playing => const Icon(LucideIcons.square),
|
SoundState.playing => const Icon(LucideIcons.square),
|
||||||
SoundState.idle || SoundState.unavailable => const Icon(
|
SoundState.idle ||
|
||||||
|
SoundState.missing ||
|
||||||
|
SoundState.unplayable => const Icon(
|
||||||
LucideIcons.play,
|
LucideIcons.play,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart
|
||||||
/// Shows [content] read-only: the one renderer for rich text wherever it is
|
/// Shows [content] read-only: the one renderer for rich text wherever it is
|
||||||
/// shown rather than edited, so every block looks the same everywhere.
|
/// shown rather than edited, so every block looks the same everywhere.
|
||||||
///
|
///
|
||||||
|
/// Display only: its text can't be selected, and a tap on it goes to what
|
||||||
|
/// surrounds it (a field opening in the composer, say); what a block holds
|
||||||
|
/// to act on, such as a sound's button, still takes its taps. The editor's
|
||||||
|
/// own selection would show a caret in text nobody can change and, on
|
||||||
|
/// phones, open the keyboard for it.
|
||||||
|
///
|
||||||
/// Takes the height of its content, so it fits into scrolling lists.
|
/// Takes the height of its content, so it fits into scrolling lists.
|
||||||
class const RichContentView({
|
class const RichContentView({
|
||||||
/// The content to show.
|
/// The content to show.
|
||||||
|
|
@ -49,6 +55,8 @@ class _RichContentViewState() extends State<RichContentView> {
|
||||||
editorStyle: editorStyleOf(context),
|
editorStyle: editorStyleOf(context),
|
||||||
blockComponentBuilders: fileBlockBuilders,
|
blockComponentBuilders: fileBlockBuilders,
|
||||||
editable: false,
|
editable: false,
|
||||||
|
disableSelectionService: true,
|
||||||
|
disableKeyboardService: true,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ Future<T?> showAdaptiveSheet<T>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
// A sheet as tall as the window, as one is with the keyboard open,
|
||||||
|
// would otherwise reach under the status bar.
|
||||||
|
useSafeArea: true,
|
||||||
showDragHandle: swipeable,
|
showDragHandle: swipeable,
|
||||||
enableDrag: swipeable,
|
enableDrag: swipeable,
|
||||||
builder: (context) => _Presentation(
|
builder: (context) => _Presentation(
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,11 @@ class const _Account() extends StatelessWidget {
|
||||||
},
|
},
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
|
// As wide as it may be (a narrower window narrows it), whatever it
|
||||||
|
// says: a short text would otherwise make a narrower card.
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints.tightFor(
|
||||||
maxWidth: readableContentMaxWidth,
|
width: readableContentMaxWidth,
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ const _collapsedStackWidth = 72.0;
|
||||||
|
|
||||||
class _CardComposerScreenState() extends State<CardComposerScreen> {
|
class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
String? _selected;
|
String? _selected;
|
||||||
|
|
||||||
|
/// The card just asked of the bloc (a new or duplicated one), until its
|
||||||
|
/// state has it.
|
||||||
|
String? _awaited;
|
||||||
bool? _stackExpanded;
|
bool? _stackExpanded;
|
||||||
var _focusFront = false;
|
var _focusFront = false;
|
||||||
var _seeded = false;
|
var _seeded = false;
|
||||||
|
|
@ -81,6 +85,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
_bloc.add(CardComposerEventDraftAdded(id, after: _selected));
|
_bloc.add(CardComposerEventDraftAdded(id, after: _selected));
|
||||||
setState(() {
|
setState(() {
|
||||||
_selected = id;
|
_selected = id;
|
||||||
|
_awaited = id;
|
||||||
_focusFront = true;
|
_focusFront = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +96,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
_bloc.add(CardComposerEventDuplicated(draft.id, id));
|
_bloc.add(CardComposerEventDuplicated(draft.id, id));
|
||||||
setState(() {
|
setState(() {
|
||||||
_selected = id;
|
_selected = id;
|
||||||
|
_awaited = id;
|
||||||
_focusFront = true;
|
_focusFront = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +174,14 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_seeded = true;
|
_seeded = true;
|
||||||
|
// A card asked for isn't gone while it isn't there yet: leaving the
|
||||||
|
// card before it changes the state first.
|
||||||
|
if (_awaited case final id?) {
|
||||||
|
if (state.byId(id) == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_awaited = null;
|
||||||
|
}
|
||||||
// Nothing open, or the open card gone (deleted, here or elsewhere):
|
// Nothing open, or the open card gone (deleted, here or elsewhere):
|
||||||
// open the first.
|
// open the first.
|
||||||
if (state.byId(_selected) == null && state.items.isNotEmpty) {
|
if (state.byId(_selected) == null && state.items.isNotEmpty) {
|
||||||
|
|
@ -193,10 +207,15 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// By how it opened: to write new cards, or on a stored one
|
||||||
|
// (decided 2026-10-02); it holds both either way.
|
||||||
Text(
|
Text(
|
||||||
widget.studysetTitle.isEmpty
|
switch ((widget.initialCardId, widget.studysetTitle)) {
|
||||||
? l10n.composerTitle
|
(null, '') => l10n.composerTitle,
|
||||||
: l10n.composerTitleFor(widget.studysetTitle),
|
(null, final title) => l10n.composerTitleFor(title),
|
||||||
|
(_, '') => l10n.composerEditTitle,
|
||||||
|
(_, final title) => l10n.composerEditTitleFor(title),
|
||||||
|
},
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (switch (state.saving) {
|
if (switch (state.saving) {
|
||||||
|
|
|
||||||
|
|
@ -198,32 +198,29 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Align(
|
// Chips rather than segments: on a phone they flow onto a second
|
||||||
alignment: Alignment.centerLeft,
|
// row, where segments would break their names.
|
||||||
child: SegmentedButton<CardKind>(
|
Wrap(
|
||||||
segments: [
|
spacing: 8,
|
||||||
ButtonSegment(
|
runSpacing: 8,
|
||||||
value: CardKind.flashcard,
|
children: [
|
||||||
label: Text(l10n.composerKindFlashcard),
|
for (final (choice, name) in [
|
||||||
|
(CardKind.flashcard, l10n.composerKindFlashcard),
|
||||||
|
(CardKind.vocabulary, l10n.composerKindVocabulary),
|
||||||
|
(CardKind.matching, l10n.composerKindMatching),
|
||||||
|
(CardKind.map, l10n.composerKindMap),
|
||||||
|
])
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(name),
|
||||||
|
selected: choice == kind,
|
||||||
|
showCheckmark: false,
|
||||||
|
onSelected: (_) {
|
||||||
|
if (choice != kind) {
|
||||||
|
_change(choice.apply(content));
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
],
|
||||||
value: CardKind.vocabulary,
|
|
||||||
label: Text(l10n.composerKindVocabulary),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: CardKind.matching,
|
|
||||||
label: Text(l10n.composerKindMatching),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: CardKind.map,
|
|
||||||
label: Text(l10n.composerKindMap),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {kind},
|
|
||||||
showSelectedIcon: false,
|
|
||||||
onSelectionChanged: (selection) =>
|
|
||||||
_change(selection.single.apply(content)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
|
||||||
|
|
@ -25,29 +25,69 @@ class const CardFace({
|
||||||
/// How tall the editor is.
|
/// How tall the editor is.
|
||||||
static const editorHeight = 200.0;
|
static const editorHeight = 200.0;
|
||||||
|
|
||||||
|
/// How tall a filled field's preview grows at most.
|
||||||
|
static const previewHeight = 160.0;
|
||||||
|
|
||||||
|
/// Over how much of its bottom a preview cut short fades out.
|
||||||
|
static const fadeHeight = 32.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CardFace> createState() => _CardFaceState();
|
State<CardFace> createState() => _CardFaceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CardFaceState() extends State<CardFace> {
|
class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
|
||||||
late var _expanded = widget.startExpanded;
|
late var _expanded = widget.startExpanded;
|
||||||
final _focus = FocusNode();
|
final _focus = FocusNode();
|
||||||
var _hovered = false;
|
var _hovered = false;
|
||||||
|
var _keyboardHeight = 0.0;
|
||||||
|
|
||||||
|
/// Whether the preview cuts the content short.
|
||||||
|
var _clipped = false;
|
||||||
|
|
||||||
|
/// Keeps the preview, the editor showing it included, when it moves in
|
||||||
|
/// and out of the fade.
|
||||||
|
final _preview = GlobalKey();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_focus.addListener(_onFocusChange);
|
_focus.addListener(_onFocusChange);
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_focus
|
_focus
|
||||||
..removeListener(_onFocusChange)
|
..removeListener(_onFocusChange)
|
||||||
..dispose();
|
..dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeMetrics() {
|
||||||
|
final keyboardHeight = View.of(context).viewInsets.bottom;
|
||||||
|
if (keyboardHeight > _keyboardHeight && _focus.hasFocus) {
|
||||||
|
_keepInView();
|
||||||
|
}
|
||||||
|
_keyboardHeight = keyboardHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the editor wholly into view once laid out, as a text field
|
||||||
|
/// does with its caret: the editor scrolls only within itself, so the
|
||||||
|
/// keyboard rising over it, or the editor growing out of the face, would
|
||||||
|
/// otherwise hide what is being written.
|
||||||
|
void _keepInView() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _expanded) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
context,
|
||||||
|
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onFocusChange() {
|
void _onFocusChange() {
|
||||||
// A dialog opening over the editor takes the focus too; collapsing
|
// A dialog opening over the editor takes the focus too; collapsing
|
||||||
// then would drop the editor that dialog works for.
|
// then would drop the editor that dialog works for.
|
||||||
|
|
@ -79,12 +119,22 @@ class _CardFaceState() extends State<CardFace> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final empty = widget.content.isBlank;
|
final empty = widget.content.isBlank;
|
||||||
|
final preview = ClipRect(
|
||||||
|
key: _preview,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
child: RichContentView(content: widget.content),
|
||||||
|
),
|
||||||
|
);
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
onEnter: (_) => setState(() => _hovered = true),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
onExit: (_) => setState(() => _hovered = false),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() => _expanded = true),
|
onTap: () {
|
||||||
|
setState(() => _expanded = true);
|
||||||
|
_keepInView();
|
||||||
|
},
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 120),
|
duration: const Duration(milliseconds: 120),
|
||||||
|
|
@ -114,14 +164,25 @@ class _CardFaceState() extends State<CardFace> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
// Tall content shows its start; the editor shows it all.
|
// Tall content shows its start, fading out so it reads as cut
|
||||||
|
// short (decided 2026-10-02); the editor shows it all.
|
||||||
: ConstrainedBox(
|
: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 160),
|
constraints: const BoxConstraints(
|
||||||
child: ClipRect(
|
maxHeight: CardFace.previewHeight,
|
||||||
child: SingleChildScrollView(
|
),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: NotificationListener<ScrollMetricsNotification>(
|
||||||
child: RichContentView(content: widget.content),
|
onNotification: (notification) {
|
||||||
),
|
// The preview's own scroll view, not one in the content.
|
||||||
|
if (notification.depth > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final clipped = notification.metrics.maxScrollExtent > 0;
|
||||||
|
if (clipped != _clipped) {
|
||||||
|
setState(() => _clipped = clipped);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: _clipped ? _Fade(child: preview) : preview,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -129,3 +190,19 @@ class _CardFaceState() extends State<CardFace> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fades [child] out over its last [CardFace.fadeHeight], into whatever is
|
||||||
|
/// behind it.
|
||||||
|
class const _Fade({required final Widget child}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ShaderMask(
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
shaderCallback: (bounds) => LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: const [Colors.black, Colors.transparent],
|
||||||
|
stops: [1 - CardFace.fadeHeight / bounds.height, 1],
|
||||||
|
).createShader(bounds),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,10 @@ class HomeBloc(
|
||||||
for (final card in cards)
|
for (final card in cards)
|
||||||
if (!card.imported) ?card.createdAt,
|
if (!card.imported) ?card.createdAt,
|
||||||
],
|
],
|
||||||
|
imported: [
|
||||||
|
for (final card in cards)
|
||||||
|
if (card.imported) ?card.createdAt,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
// Cards resting in an archived studyset fall due nowhere.
|
// Cards resting in an archived studyset fall due nowhere.
|
||||||
final resting = {
|
final resting = {
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,32 @@ DateTime mondayOf(DateTime day) => addDays(day, -(day.weekday - 1));
|
||||||
/// included, and cards written by hand (decided 2026-10-01).
|
/// included, and cards written by hand (decided 2026-10-01).
|
||||||
final class const StudyActivity(
|
final class const StudyActivity(
|
||||||
/// What happened on each day, by the day.
|
/// What happened on each day, by the day.
|
||||||
final Map<DateTime, int> byDay,
|
final Map<DateTime, int> byDay, {
|
||||||
) with Equatable {
|
|
||||||
|
/// The first day anything happened, imports included; null while nothing
|
||||||
|
/// has.
|
||||||
|
final DateTime? start,
|
||||||
|
}) with Equatable {
|
||||||
/// The activity of [ratings] and [written] cards, each an instant, by the
|
/// The activity of [ratings] and [written] cards, each an instant, by the
|
||||||
/// local day it fell on.
|
/// local day it fell on. Cards [imported] count only for its [start].
|
||||||
factory of({
|
factory of({
|
||||||
required Iterable<DateTime> ratings,
|
required Iterable<DateTime> ratings,
|
||||||
required Iterable<DateTime> written,
|
required Iterable<DateTime> written,
|
||||||
|
Iterable<DateTime> imported = const [],
|
||||||
}) {
|
}) {
|
||||||
final byDay = <DateTime, int>{};
|
final byDay = <DateTime, int>{};
|
||||||
for (final instant in [...ratings, ...written]) {
|
for (final instant in [...ratings, ...written]) {
|
||||||
final day = localDay(instant);
|
final day = localDay(instant);
|
||||||
byDay[day] = (byDay[day] ?? 0) + 1;
|
byDay[day] = (byDay[day] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return StudyActivity(byDay);
|
DateTime? start;
|
||||||
|
for (final instant in [...ratings, ...written, ...imported]) {
|
||||||
|
final day = localDay(instant);
|
||||||
|
if (start == null || day.isBefore(start)) {
|
||||||
|
start = day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StudyActivity(byDay, start: start);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No activity at all.
|
/// No activity at all.
|
||||||
|
|
@ -44,7 +56,7 @@ final class const StudyActivity(
|
||||||
bool studiedOn(DateTime day) => on(day) > 0;
|
bool studiedOn(DateTime day) => on(day) > 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [byDay];
|
List<Object?> get props => [byDay, start];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The streak: how many days in a row the person studied or rested, up to
|
/// The streak: how many days in a row the person studied or rested, up to
|
||||||
|
|
@ -74,6 +86,10 @@ enum DayState() {
|
||||||
/// Past, without studying or resting.
|
/// Past, without studying or resting.
|
||||||
missed,
|
missed,
|
||||||
|
|
||||||
|
/// Past, before the person's first activity: nothing to miss yet
|
||||||
|
/// (decided 2026-10-02).
|
||||||
|
notStarted,
|
||||||
|
|
||||||
/// Today, not studied yet.
|
/// Today, not studied yet.
|
||||||
today,
|
today,
|
||||||
|
|
||||||
|
|
@ -93,6 +109,7 @@ List<WeekDay> weekOf(
|
||||||
) {
|
) {
|
||||||
final today = localDay(now);
|
final today = localDay(now);
|
||||||
final monday = mondayOf(day);
|
final monday = mondayOf(day);
|
||||||
|
final start = activity.start;
|
||||||
return [
|
return [
|
||||||
for (var i = 0; i < 7; i++)
|
for (var i = 0; i < 7; i++)
|
||||||
() {
|
() {
|
||||||
|
|
@ -108,6 +125,8 @@ List<WeekDay> weekOf(
|
||||||
? DayState.today
|
? DayState.today
|
||||||
: date.isAfter(today)
|
: date.isAfter(today)
|
||||||
? DayState.upcoming
|
? DayState.upcoming
|
||||||
|
: start == null || date.isBefore(start)
|
||||||
|
? DayState.notStarted
|
||||||
: DayState.missed,
|
: DayState.missed,
|
||||||
);
|
);
|
||||||
}(),
|
}(),
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,8 @@ class const _StreakDay({required final WeekDay day}) extends StatelessWidget {
|
||||||
|
|
||||||
/// How a day stands, as a small round mark: a check for studied, a
|
/// How a day stands, as a small round mark: a check for studied, a
|
||||||
/// snowflake for rested, a cross for missed, a dashed ring for today; a day
|
/// snowflake for rested, a cross for missed, a dashed ring for today; a day
|
||||||
/// to come shows how many cards fall due, or a faint dashed ring.
|
/// to come shows how many cards fall due, or a faint dashed ring; a day
|
||||||
|
/// before the person's first activity shows nothing.
|
||||||
class const DayMarker({
|
class const DayMarker({
|
||||||
required final WeekDay day,
|
required final WeekDay day,
|
||||||
required final int due,
|
required final int due,
|
||||||
|
|
@ -186,6 +187,7 @@ class const DayMarker({
|
||||||
colors.surfaceContainerHighest,
|
colors.surfaceContainerHighest,
|
||||||
Icon(LucideIcons.x, size: 14, color: colors.onSurfaceVariant),
|
Icon(LucideIcons.x, size: 14, color: colors.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
|
DayState.notStarted => const SizedBox.square(dimension: _size),
|
||||||
DayState.today => dashed(colors.primary, width: 2),
|
DayState.today => dashed(colors.primary, width: 2),
|
||||||
DayState.upcoming when due > 0 => solid(
|
DayState.upcoming when due > 0 => solid(
|
||||||
colors.secondaryContainer,
|
colors.secondaryContainer,
|
||||||
|
|
|
||||||
|
|
@ -72,17 +72,26 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'is unavailable while its file is not on this device or the server',
|
'is missing while its file is not on this device or the server',
|
||||||
() async {
|
() async {
|
||||||
final bloc = SoundBloc(assets, player, 'not-uploaded')
|
final bloc = SoundBloc(assets, player, 'not-uploaded')
|
||||||
..add(const SoundEventToggled());
|
..add(const SoundEventToggled());
|
||||||
|
|
||||||
await next(bloc, SoundState.unavailable);
|
await next(bloc, SoundState.missing);
|
||||||
expect(player.played, isEmpty);
|
expect(player.played, isEmpty);
|
||||||
await bloc.close();
|
await bloc.close();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test("is unplayable when its file is here but won't play", () async {
|
||||||
|
player.failPlay = Exception('Format not supported');
|
||||||
|
final bloc = SoundBloc(assets, player, sound)
|
||||||
|
..add(const SoundEventToggled());
|
||||||
|
|
||||||
|
await next(bloc, SoundState.unplayable);
|
||||||
|
await bloc.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('stops its sound when it goes', () async {
|
test('stops its sound when it goes', () async {
|
||||||
final bloc = SoundBloc(assets, player, sound)
|
final bloc = SoundBloc(assets, player, sound)
|
||||||
..add(const SoundEventToggled());
|
..add(const SoundEventToggled());
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,22 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets("says so for an image whose file is here but can't be read", (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final image = await add('no image at all', 'image/png');
|
||||||
|
|
||||||
|
await study(tester, content([fileImageNode(image)]));
|
||||||
|
// Decoding runs in the engine, beyond the test's fake clock.
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 200)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining("This image can't be shown"), findsOneWidget);
|
||||||
|
expect(find.textContaining("isn't on this device"), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('fetches no image from the web', (tester) async {
|
testWidgets('fetches no image from the web', (tester) async {
|
||||||
await study(
|
await study(
|
||||||
tester,
|
tester,
|
||||||
|
|
@ -154,4 +170,19 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(sounds.played, isEmpty);
|
expect(sounds.played, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets("says so for a sound whose file is here but won't play", (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final sound = await add('neko', 'audio/mpeg');
|
||||||
|
sounds.failPlay = Exception('Format not supported');
|
||||||
|
|
||||||
|
await study(tester, content([audioNode(sound)]));
|
||||||
|
await tester.tap(find.text('Play sound'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining("This sound can't be played"), findsOneWidget);
|
||||||
|
// Trying again wouldn't change the file.
|
||||||
|
expect(find.text('Try again'), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,4 +45,30 @@ void main() {
|
||||||
expect(find.textContaining('犬', findRichText: true), findsOneWidget);
|
expect(find.textContaining('犬', findRichText: true), findsOneWidget);
|
||||||
expect(find.textContaining('猫', findRichText: true), findsNothing);
|
expect(find.textContaining('猫', findRichText: true), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('is display only: a tap on its text goes to what surrounds it, '
|
||||||
|
'and no keyboard opens', (tester) async {
|
||||||
|
var tapped = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => tapped++,
|
||||||
|
child: RichContentView(content: paragraph('猫は動物です。')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
await tester.tap(find.textContaining('猫', findRichText: true));
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
expect(tapped, 1);
|
||||||
|
expect(tester.testTextInput.hasAnyClients, isFalse);
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,17 @@ import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
/// Opens a sheet titled "New studyset" with [body] and a Create button, in
|
/// Opens a sheet titled "New studyset" with [body] and a Create button, in
|
||||||
/// a window [width] wide.
|
/// a window [width] wide, with a status bar [statusBar] high on top.
|
||||||
Future<void> open(
|
Future<void> open(
|
||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
required double width,
|
required double width,
|
||||||
Widget body = const Text('Body'),
|
Widget body = const Text('Body'),
|
||||||
|
double statusBar = 0,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = Size(width, 900);
|
tester.view.physicalSize = Size(width, 900);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.padding = FakeViewPadding(top: statusBar);
|
||||||
|
tester.view.viewPadding = FakeViewPadding(top: statusBar);
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
|
@ -72,6 +75,26 @@ void main() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets('stays below the status bar with the keyboard open', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(
|
||||||
|
tester,
|
||||||
|
width: 400,
|
||||||
|
statusBar: 48,
|
||||||
|
body: Column(
|
||||||
|
children: [for (var i = 0; i < 80; i++) Text('Line $i')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.viewInsets = const FakeViewPadding(bottom: 400);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.getTopLeft(find.byType(BottomSheet)).dy, 48);
|
||||||
|
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
|
||||||
|
expect(find.text('Create').hitTestable(), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('closes with its close button', (tester) async {
|
testWidgets('closes with its close button', (tester) async {
|
||||||
await open(tester, width: 400);
|
await open(tester, width: 400);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ void main() {
|
||||||
package.mediaBytes('cat.png')?.sublist(0, 8),
|
package.mediaBytes('cat.png')?.sublist(0, 8),
|
||||||
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
||||||
);
|
);
|
||||||
expect(package.mediaBytes('neko.mp3'), hasLength(203));
|
expect(package.mediaBytes('neko.mp3'), hasLength(4170));
|
||||||
expect(package.mediaBytes('missing.png'), isNull);
|
expect(package.mediaBytes('missing.png'), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,7 +129,7 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(package.collection.notes, hasLength(4));
|
expect(package.collection.notes, hasLength(4));
|
||||||
expect(package.mediaBytes('neko.mp3'), hasLength(203));
|
expect(package.mediaBytes('neko.mp3'), hasLength(4170));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue