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
28 changed files with 552 additions and 73 deletions
|
|
@ -172,6 +172,9 @@
|
|||
"composerTitle": "Create flashcards",
|
||||
"composerTitleFor": "Create flashcards for \"{title}\"",
|
||||
"@composerTitleFor": {"placeholders": {"title": {"type": "String"}}},
|
||||
"composerEditTitle": "Edit flashcards",
|
||||
"composerEditTitleFor": "Edit flashcards in \"{title}\"",
|
||||
"@composerEditTitleFor": {"placeholders": {"title": {"type": "String"}}},
|
||||
"composerSaving": "Saving…",
|
||||
"composerSaveFailed": "Save failed — retry",
|
||||
"composerAllSaved": "All changes saved",
|
||||
|
|
@ -376,10 +379,12 @@
|
|||
"fileImage": "Image",
|
||||
"fileSound": "Sound",
|
||||
"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.",
|
||||
"filePlaySound": "Play sound",
|
||||
"fileStopSound": "Stop sound",
|
||||
"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",
|
||||
"ankiImportFileType": "Anki decks",
|
||||
"ankiImportTitle": "Import Anki deck",
|
||||
|
|
|
|||
|
|
@ -970,6 +970,18 @@ abstract class AppLocalizations {
|
|||
/// **'Create flashcards for \"{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.
|
||||
///
|
||||
/// 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.'**
|
||||
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.
|
||||
///
|
||||
/// 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.'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -522,6 +522,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Create flashcards for \"$title\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get composerEditTitle => 'Edit flashcards';
|
||||
|
||||
@override
|
||||
String composerEditTitleFor(String title) {
|
||||
return 'Edit flashcards in \"$title\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get composerSaving => 'Saving…';
|
||||
|
||||
|
|
@ -1179,6 +1187,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get fileImageUnavailable =>
|
||||
'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
|
||||
String get fileFromElsewhere => 'Files from the web aren\'t shown.';
|
||||
|
||||
|
|
@ -1192,6 +1204,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get fileSoundUnavailable =>
|
||||
'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
|
||||
String get ankiImportMenu => 'Import from Anki';
|
||||
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ class SoundBloc(
|
|||
await _player.stop();
|
||||
case SoundState.loading:
|
||||
break;
|
||||
case SoundState.idle || SoundState.unavailable:
|
||||
case SoundState.idle || SoundState.missing || SoundState.unplayable:
|
||||
emit(SoundState.loading);
|
||||
final file = await _assets.fileOf(_assetId);
|
||||
if (file == null) {
|
||||
emit(SoundState.unavailable);
|
||||
emit(SoundState.missing);
|
||||
return;
|
||||
}
|
||||
_path = file.path;
|
||||
|
|
@ -55,7 +55,7 @@ class SoundBloc(
|
|||
await _player.play(file.path, mimeType: file.mimeType);
|
||||
} on Object catch (error) {
|
||||
log('Playing a sound failed', name: 'Sound', error: error);
|
||||
emit(SoundState.unavailable);
|
||||
emit(SoundState.unplayable);
|
||||
}
|
||||
// Playing shows once the player says so.
|
||||
}
|
||||
|
|
@ -100,7 +100,10 @@ enum SoundState() {
|
|||
/// Playing.
|
||||
playing,
|
||||
|
||||
/// Its file can't be had now (not uploaded yet, or no connection), or
|
||||
/// didn't play.
|
||||
unavailable,
|
||||
/// Its file can't be had now: not uploaded yet, or no connection.
|
||||
missing,
|
||||
|
||||
/// 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);
|
||||
if (file == null) {
|
||||
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));
|
||||
}
|
||||
|
|
@ -47,3 +47,13 @@ class const StoredFileImage(
|
|||
@override
|
||||
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,
|
||||
errorBuilder: (context, error, stackTrace) => _Unavailable(
|
||||
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) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.watch<SoundBloc>().state;
|
||||
if (state == SoundState.unavailable) {
|
||||
return _Unavailable(
|
||||
icon: LucideIcons.volumeOff,
|
||||
text: l10n.fileSoundUnavailable,
|
||||
onRetry: () => context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||
);
|
||||
switch (state) {
|
||||
case SoundState.missing:
|
||||
return _Unavailable(
|
||||
icon: LucideIcons.volumeOff,
|
||||
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(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
|
|
@ -251,7 +263,9 @@ class const _SoundButton() extends StatelessWidget {
|
|||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SoundState.playing => const Icon(LucideIcons.square),
|
||||
SoundState.idle || SoundState.unavailable => const Icon(
|
||||
SoundState.idle ||
|
||||
SoundState.missing ||
|
||||
SoundState.unplayable => const Icon(
|
||||
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
|
||||
/// 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.
|
||||
class const RichContentView({
|
||||
/// The content to show.
|
||||
|
|
@ -49,6 +55,8 @@ class _RichContentViewState() extends State<RichContentView> {
|
|||
editorStyle: editorStyleOf(context),
|
||||
blockComponentBuilders: fileBlockBuilders,
|
||||
editable: false,
|
||||
disableSelectionService: true,
|
||||
disableKeyboardService: true,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ Future<T?> showAdaptiveSheet<T>(
|
|||
context: context,
|
||||
useRootNavigator: 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,
|
||||
enableDrag: swipeable,
|
||||
builder: (context) => _Presentation(
|
||||
|
|
|
|||
|
|
@ -89,9 +89,11 @@ class const _Account() extends StatelessWidget {
|
|||
},
|
||||
child: Align(
|
||||
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(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: readableContentMaxWidth,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ const _collapsedStackWidth = 72.0;
|
|||
|
||||
class _CardComposerScreenState() extends State<CardComposerScreen> {
|
||||
String? _selected;
|
||||
|
||||
/// The card just asked of the bloc (a new or duplicated one), until its
|
||||
/// state has it.
|
||||
String? _awaited;
|
||||
bool? _stackExpanded;
|
||||
var _focusFront = false;
|
||||
var _seeded = false;
|
||||
|
|
@ -81,6 +85,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
_bloc.add(CardComposerEventDraftAdded(id, after: _selected));
|
||||
setState(() {
|
||||
_selected = id;
|
||||
_awaited = id;
|
||||
_focusFront = true;
|
||||
});
|
||||
}
|
||||
|
|
@ -91,6 +96,7 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
_bloc.add(CardComposerEventDuplicated(draft.id, id));
|
||||
setState(() {
|
||||
_selected = id;
|
||||
_awaited = id;
|
||||
_focusFront = true;
|
||||
});
|
||||
}
|
||||
|
|
@ -168,6 +174,14 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
return;
|
||||
}
|
||||
_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):
|
||||
// open the first.
|
||||
if (state.byId(_selected) == null && state.items.isNotEmpty) {
|
||||
|
|
@ -193,10 +207,15 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// By how it opened: to write new cards, or on a stored one
|
||||
// (decided 2026-10-02); it holds both either way.
|
||||
Text(
|
||||
widget.studysetTitle.isEmpty
|
||||
? l10n.composerTitle
|
||||
: l10n.composerTitleFor(widget.studysetTitle),
|
||||
switch ((widget.initialCardId, widget.studysetTitle)) {
|
||||
(null, '') => l10n.composerTitle,
|
||||
(null, final title) => l10n.composerTitleFor(title),
|
||||
(_, '') => l10n.composerEditTitle,
|
||||
(_, final title) => l10n.composerEditTitleFor(title),
|
||||
},
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (switch (state.saving) {
|
||||
|
|
|
|||
|
|
@ -198,32 +198,29 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<CardKind>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: CardKind.flashcard,
|
||||
label: Text(l10n.composerKindFlashcard),
|
||||
// Chips rather than segments: on a phone they flow onto a second
|
||||
// row, where segments would break their names.
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
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),
|
||||
Expanded(
|
||||
|
|
|
|||
|
|
@ -25,29 +25,69 @@ class const CardFace({
|
|||
/// How tall the editor is.
|
||||
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
|
||||
State<CardFace> createState() => _CardFaceState();
|
||||
}
|
||||
|
||||
class _CardFaceState() extends State<CardFace> {
|
||||
class _CardFaceState() extends State<CardFace> with WidgetsBindingObserver {
|
||||
late var _expanded = widget.startExpanded;
|
||||
final _focus = FocusNode();
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(_onFocusChange);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_focus
|
||||
..removeListener(_onFocusChange)
|
||||
..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() {
|
||||
// A dialog opening over the editor takes the focus too; collapsing
|
||||
// then would drop the editor that dialog works for.
|
||||
|
|
@ -79,12 +119,22 @@ class _CardFaceState() extends State<CardFace> {
|
|||
);
|
||||
}
|
||||
final empty = widget.content.isBlank;
|
||||
final preview = ClipRect(
|
||||
key: _preview,
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: RichContentView(content: widget.content),
|
||||
),
|
||||
);
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _expanded = true),
|
||||
onTap: () {
|
||||
setState(() => _expanded = true);
|
||||
_keepInView();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
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(
|
||||
constraints: const BoxConstraints(maxHeight: 160),
|
||||
child: ClipRect(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: RichContentView(content: widget.content),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: CardFace.previewHeight,
|
||||
),
|
||||
child: NotificationListener<ScrollMetricsNotification>(
|
||||
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)
|
||||
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.
|
||||
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).
|
||||
final class const StudyActivity(
|
||||
/// What happened on each day, by the day.
|
||||
final Map<DateTime, int> byDay,
|
||||
) with Equatable {
|
||||
final Map<DateTime, int> byDay, {
|
||||
|
||||
/// 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
|
||||
/// local day it fell on.
|
||||
/// local day it fell on. Cards [imported] count only for its [start].
|
||||
factory of({
|
||||
required Iterable<DateTime> ratings,
|
||||
required Iterable<DateTime> written,
|
||||
Iterable<DateTime> imported = const [],
|
||||
}) {
|
||||
final byDay = <DateTime, int>{};
|
||||
for (final instant in [...ratings, ...written]) {
|
||||
final day = localDay(instant);
|
||||
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.
|
||||
|
|
@ -44,7 +56,7 @@ final class const StudyActivity(
|
|||
bool studiedOn(DateTime day) => on(day) > 0;
|
||||
|
||||
@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
|
||||
|
|
@ -74,6 +86,10 @@ enum DayState() {
|
|||
/// Past, without studying or resting.
|
||||
missed,
|
||||
|
||||
/// Past, before the person's first activity: nothing to miss yet
|
||||
/// (decided 2026-10-02).
|
||||
notStarted,
|
||||
|
||||
/// Today, not studied yet.
|
||||
today,
|
||||
|
||||
|
|
@ -93,6 +109,7 @@ List<WeekDay> weekOf(
|
|||
) {
|
||||
final today = localDay(now);
|
||||
final monday = mondayOf(day);
|
||||
final start = activity.start;
|
||||
return [
|
||||
for (var i = 0; i < 7; i++)
|
||||
() {
|
||||
|
|
@ -108,6 +125,8 @@ List<WeekDay> weekOf(
|
|||
? DayState.today
|
||||
: date.isAfter(today)
|
||||
? DayState.upcoming
|
||||
: start == null || date.isBefore(start)
|
||||
? DayState.notStarted
|
||||
: 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
|
||||
/// 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({
|
||||
required final WeekDay day,
|
||||
required final int due,
|
||||
|
|
@ -186,6 +187,7 @@ class const DayMarker({
|
|||
colors.surfaceContainerHighest,
|
||||
Icon(LucideIcons.x, size: 14, color: colors.onSurfaceVariant),
|
||||
),
|
||||
DayState.notStarted => const SizedBox.square(dimension: _size),
|
||||
DayState.today => dashed(colors.primary, width: 2),
|
||||
DayState.upcoming when due > 0 => solid(
|
||||
colors.secondaryContainer,
|
||||
|
|
|
|||
|
|
@ -72,17 +72,26 @@ void main() {
|
|||
);
|
||||
|
||||
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 {
|
||||
final bloc = SoundBloc(assets, player, 'not-uploaded')
|
||||
..add(const SoundEventToggled());
|
||||
|
||||
await next(bloc, SoundState.unavailable);
|
||||
await next(bloc, SoundState.missing);
|
||||
expect(player.played, isEmpty);
|
||||
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 {
|
||||
final bloc = SoundBloc(assets, player, sound)
|
||||
..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 {
|
||||
await study(
|
||||
tester,
|
||||
|
|
@ -154,4 +170,19 @@ void main() {
|
|||
);
|
||||
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), 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() {
|
||||
/// 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(
|
||||
WidgetTester tester, {
|
||||
required double width,
|
||||
Widget body = const Text('Body'),
|
||||
double statusBar = 0,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = FakeViewPadding(top: statusBar);
|
||||
tester.view.viewPadding = FakeViewPadding(top: statusBar);
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
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 {
|
||||
await open(tester, width: 400);
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ void main() {
|
|||
package.mediaBytes('cat.png')?.sublist(0, 8),
|
||||
[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);
|
||||
});
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ void main() {
|
|||
);
|
||||
|
||||
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