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
28 changed files with 552 additions and 73 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

View file

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

View file

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

View file

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