Fix what the first Android run found
All checks were successful
CI / check (push) Successful in 6m44s
CI / deploy (push) Successful in 59s

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:
Tizian 「ティツィアーン」 2026-10-02 19:52:39 +02:00
commit 079179d2ed
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 552 additions and 73 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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,
} }

View file

@ -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.';
}

View file

@ -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,
), ),
}, },

View file

@ -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,
), ),
); );

View file

@ -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(

View file

@ -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),

View file

@ -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) {

View file

@ -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(

View file

@ -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,
);
}

View file

@ -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 = {

View file

@ -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,
); );
}(), }(),

View file

@ -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,

View file

@ -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());

View file

@ -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);
});
} }

View file

@ -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());
} }

View file

@ -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);

View file

@ -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