Text-free list, editor, sync and image cues

Glyph + spoken label replaces every app-written string the guard
found: empty states, tile meta, sheet dates/fields, date presets,
template/section headings, status footer, snackbars, GitHub and
cloud-image results, upload/download badges, Material tooltips.
Guard tests (text_free_*_test) walk the painted tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:15:36 +01:00
parent 091e68a2d9
commit 5ea7ba7b73
50 changed files with 927 additions and 249 deletions

View File

@ -11,6 +11,7 @@ import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repository_factory.dart';
import 'package:todo/frame_stats.dart';
import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/text_free_localizations.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -69,6 +70,7 @@ class TodoApp extends StatelessWidget {
store: imageStore,
child: MaterialApp(
title: 'todo',
localizationsDelegates: kTextFreeLocalizations,
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
home: CaptureScreen(

View File

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
@ -22,6 +21,7 @@ import 'package:todo/ui/capture_draft.dart';
import 'package:todo/ui/capture_status.dart';
import 'package:todo/ui/capture_status_footer.dart';
import 'package:todo/ui/capture_sync_runner.dart';
import 'package:todo/ui/cue_snack.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/note_form.dart';
@ -194,7 +194,14 @@ class _CaptureScreenState extends State<CaptureScreen>
Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load();
if (!settings.isConfigured && await openFirebase() == null) {
_showSnack('Connect Firebase (or add a GitHub token) in settings');
// The cue plus the settings screen opening is the whole message.
if (mounted) {
showCue(
context,
Icons.cloud_off,
'Connect Firebase (or add a GitHub token) in settings',
);
}
await _openSettings();
return;
}
@ -215,12 +222,7 @@ class _CaptureScreenState extends State<CaptureScreen>
if (outcome.ok) 'detail': outcome.detail,
},
);
_showSnack(
outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}',
isError: !outcome.ok,
);
_showSyncCue(outcome);
} finally {
if (mounted) setState(() => _syncing = false);
}

View File

@ -104,13 +104,20 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
/// replace-don't-queue behaviour match every other app; [isError] picks
/// the danger hue and the longer duration, because an error the user
/// missed is an error they will hit again.
void _showSnack(String message, {bool isError = false}) {
/// A manual sync's result as a glyph: done, done-but-degraded (Firebase
/// off or a peer unreadable, in the danger hue), or failed. The summary
/// sentence is spoken only; the status line keeps the failure time.
void _showSyncCue(CaptureSyncOutcome outcome) {
if (!mounted) return;
if (isError) {
showError(context, message);
} else {
showToast(context, message);
}
final spoken = outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}';
final (icon, error) = !outcome.ok
? (Icons.sync_problem, true)
: outcome.degraded
? (Icons.cloud_off, true)
: (Icons.cloud_done, false);
showCue(context, icon, spoken, error: error);
}
/// Reloads the persisted last-sync outcome so the status line survives a

View File

@ -44,16 +44,16 @@ class CaptureStatusFooter extends StatelessWidget {
if (status == null || status.ok) {
return const SizedBox.shrink();
}
// Glyph + time in the danger hue; the error itself is spoken.
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
child: _cueLine(
Icons.sync_problem,
formatSyncTime(status.time),
'Sync failed at ${formatSyncTime(status.time)} · '
'${status.detail}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
theme.textTheme.bodySmall,
theme.colorScheme.error,
),
);
},
@ -63,15 +63,45 @@ class CaptureStatusFooter extends StatelessWidget {
padding: const EdgeInsets.only(top: 4),
child: ValueListenableBuilder<DateTime?>(
valueListenable: lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${formatSyncTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
// Before the first save: the save glyph alone, muted.
builder: (context, savedAt, _) => savedAt == null
? _cueLine(
Icons.save_outlined,
'',
'Autosaves as you type',
theme.textTheme.bodySmall,
theme.colorScheme.outline,
)
: _cueLine(
Icons.save,
formatSyncTime(savedAt),
'Saved locally at ${formatSyncTime(savedAt)}',
theme.textTheme.bodySmall,
null,
),
),
),
],
);
}
/// One status line: a small glyph and a time, spoken as [spoken].
Widget _cueLine(
IconData icon,
String time,
String spoken,
TextStyle? style,
Color? color,
) => Semantics(
label: spoken,
excludeSemantics: true,
container: true,
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Text(time, style: style?.copyWith(color: color)),
],
),
);
}

View File

@ -26,11 +26,16 @@ class CaptureSyncOutcome {
required this.ok,
required this.detail,
this.reconciled,
this.degraded = false,
});
/// Whether the pass completed without throwing.
final bool ok;
/// A pass that completed but not fully: Firebase was not connected, or a
/// peer's file could not be read. Shown as a warning, not a plain success.
final bool degraded;
/// One-line summary: merged/skipped counts on success, the error otherwise.
final String detail;
@ -90,10 +95,12 @@ class CaptureSyncRunner {
);
final detail = describeSyncResult(result.syncResult);
await status.record(ok: true, detail: detail, live: live());
final synced = result.syncResult;
return CaptureSyncOutcome(
ok: true,
detail: detail,
reconciled: result.appSettings,
degraded: !synced.firebaseConnected || synced.skippedFiles.isNotEmpty,
);
} on Exception catch (e) {
await status.record(ok: false, detail: '$e', live: live());

View File

@ -11,6 +11,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:todo/attachments/image_password_store.dart';
@ -18,6 +19,7 @@ import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/status_cue.dart';
/// The command that creates the login, shown wherever it is missing.
const kAddLoginCommand =
@ -68,7 +70,7 @@ class CloudImagesScreen extends StatefulWidget {
class _CloudImagesScreenState extends State<CloudImagesScreen> {
final _password = TextEditingController();
ImageStoreStatus? _status;
String? _checkResult;
StatusCue? _checkResult;
bool _checking = false;
@override
@ -97,7 +99,11 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
final password = _password.text.trim();
setState(() => _checking = true);
final saved = await widget.passwords.write(password);
var result = 'Could not save: the keystore is unavailable';
var result = const StatusCue(
Icons.no_encryption,
'Could not save: the keystore is unavailable',
error: true,
);
if (saved) {
final client = DufsImageClient(
DufsLogin(user: kDufsImagesUser, password: password),
@ -105,9 +111,17 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
);
final check = await client.check();
result = switch (check) {
DufsCheck.ok => 'Saved — dufs accepted the login',
DufsCheck.rejected => 'Saved, but dufs rejected the password',
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
DufsCheck.rejected => const StatusCue(
Icons.lock,
'Saved, but dufs rejected the password',
error: true,
),
DufsCheck.unreachable => const StatusCue(
Icons.cloud_off,
'Saved — dufs is unreachable right now',
error: true,
),
};
// Upload what queued up while there was no password, now rather than
// on the next sync. No note texts: an empty ref set only uploads, it
@ -152,23 +166,38 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
),
if (_checkResult case final result?) ...[
const SizedBox(height: 8),
Text(result),
StatusCueView(result),
],
const SizedBox(height: 16),
],
if (status != null) ...[
// Shape + colour: a filled check, or a red error ring followed by
// the command (user-typed text, so it stays) that fixes it.
Icon(
status.configured ? Icons.check_circle : Icons.error_outline,
color: status.configured
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
semanticLabel: status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
// a copy button that puts the fixing command on the clipboard.
Row(
children: [
Icon(
status.configured ? Icons.check_circle : Icons.error_outline,
color: status.configured
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
semanticLabel: status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
),
if (!status.configured)
IconButton(
onPressed: () => unawaited(
Clipboard.setData(
const ClipboardData(text: kAddLoginCommand),
),
),
icon: const Icon(
Icons.content_copy,
semanticLabel: 'Copy: $kAddLoginCommand',
),
),
],
),
if (!status.configured) const SelectableText(kAddLoginCommand),
const SizedBox(height: 8),
// Icon + the count itself: the number is the information.
Row(

58
lib/ui/cue_snack.dart Normal file
View File

@ -0,0 +1,58 @@
/// Transient feedback as a glyph, not a sentence (kuhy's textgate rule).
///
/// The design system's `showToast`/`showError` paint a message; this paints
/// an icon (plus an optional number) and keeps the old sentence as the
/// spoken label only. Colour is the second channel: errors use the danger
/// hue and stay up longer, as `showError` does.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
const Duration _toastDuration = Duration(seconds: 3);
const Duration _errorDuration = Duration(seconds: 5);
/// Shows [icon] (and [count], if given) in a snack bar; [spoken] is what a
/// screen reader says. Returns quietly when no `ScaffoldMessenger` is above
/// [context] (an async callback outliving its screen).
void showCue(
BuildContext context,
IconData icon,
String spoken, {
bool error = false,
int? count,
}) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
final colors = context.statusColors;
final fg = error ? colors.onStatus : null;
// Replace rather than queue: a queued cue describes an action the user
// has already moved on from.
messenger
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Semantics(
label: spoken,
excludeSemantics: true,
// Its own node: otherwise the label merges into the snack bar's.
container: true,
child: Row(
children: [
Icon(icon, color: fg),
if (count != null) ...[
const SizedBox(width: AppSpacing.sm),
Text('$count', style: TextStyle(color: fg)),
],
],
),
),
backgroundColor: error ? colors.danger : null,
duration: error ? _errorDuration : _toastDuration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
);
}

View File

@ -0,0 +1,23 @@
/// The notes list's empty state as a picture, not a sentence.
library;
import 'package:flutter/material.dart';
/// A large muted glyph: an empty inbox when there are no notes at all, a
/// crossed-out filter when the current filter hides every note (the filter
/// button's badge is the second channel saying a filter is on).
class EmptyNotesCue extends StatelessWidget {
/// Creates the cue; [filtered] picks the filter variant.
const EmptyNotesCue({required this.filtered, super.key});
/// Whether a filter is active (notes may exist but none match).
final bool filtered;
@override
Widget build(BuildContext context) => Icon(
filtered ? Icons.filter_alt_off_outlined : Icons.inbox_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline,
semanticLabel: filtered ? 'No notes match these filters' : 'No notes yet',
);
}

View File

@ -9,6 +9,7 @@ import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/run_sync.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/status_cue.dart';
part 'github_mirror_screen_widget.dart';
@ -27,7 +28,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
);
bool _testing = false;
String? _status;
StatusCue? _status;
@override
void dispose() {
@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
Future<void> _connectGitHub() async {
final clientId = _clientId.text.trim();
if (clientId.isEmpty) {
setState(() => _status = 'Enter the OAuth App client id first.');
return;
return _show(
const StatusCue(
Icons.key_off,
'Enter the OAuth App client id first.',
error: true,
),
);
}
final auth = GitHubDeviceAuth(
clientId: clientId,
@ -67,20 +73,19 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
if (token != null && token.isNotEmpty) {
setState(() {
_token.text = token;
_status = 'Connected — syncing…';
_status = const StatusCue(Icons.sync, 'Connected — syncing…');
});
await _current.save();
await _syncAfterConnect();
}
} on Exception catch (e) {
if (mounted) setState(() => _status = 'Could not start device flow: $e');
_show(StatusCue.failed('Could not start device flow: $e'));
} finally {
auth.close();
}
}
/// Runs a sync right after connecting so the user's notes download
/// immediately and they get clear confirmation it worked.
/// Syncs right after connecting so notes download at once, with a cue.
Future<void> _syncAfterConnect() async {
final s = _current;
try {
@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
widget.appSettings.value = widget.appSettings.value.adopt(
run.appSettings,
);
if (mounted) {
setState(
() => _status =
'Connected and synced (merged '
'${run.syncResult.mergedDevices} device(s)). Your notes are '
'up to date.',
);
}
_show(
StatusCue.ok(
'Connected and synced (merged '
'${run.syncResult.mergedDevices} device(s)). Your notes are '
'up to date.',
),
);
} on Exception catch (e) {
if (mounted) setState(() => _status = 'Connected, but sync failed: $e');
_show(
StatusCue(
Icons.sync_problem,
'Connected, but sync failed: $e',
error: true,
),
);
}
}
/// Shows [cue] as the screen's latest result, if still mounted.
void _show(StatusCue cue) {
if (mounted) setState(() => _status = cue);
}
Future<void> _test() async {
setState(() {
_testing = true;
@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
);
try {
final ok = await client.canAccessRepo();
setState(
() => _status = ok
? 'Connected — repo is reachable.'
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
_show(
ok
? const StatusCue.ok('Connected — repo is reachable.')
: StatusCue(
Icons.lock,
'Could not access ${s.owner}/${s.repo}. Check token scope.',
error: true,
),
);
} on Exception catch (e) {
setState(() => _status = 'Error: $e');
_show(StatusCue.failed('Error: $e'));
} finally {
client.close();
if (mounted) setState(() => _testing = false);
@ -154,12 +173,6 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Authorize in your browser — no token to paste. Syncs to '
'kuhyx/syncs by default.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: IconButton.filled(
@ -228,7 +241,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
),
if (_status != null) ...[
const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
StatusCueView(_status!),
],
],
),

View File

@ -9,12 +9,12 @@ library;
import 'dart:typed_data';
import 'package:design_system/design_system.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/cue_snack.dart';
/// Lets a screen insert lines into whichever editor is currently mounted.
class ImageAttachController {
@ -73,11 +73,15 @@ Future<void> attachImages(
target.insert([for (final ref in refs) ref.imageMarkdown]);
if (!context.mounted) return;
if (failure != null) {
showError(context, failure);
showCue(context, Icons.cloud_off, failure, error: true);
} else if (notImages > 0) {
showError(
// A broken-image glyph plus how many were refused.
showCue(
context,
Icons.broken_image_outlined,
notImages == 1 ? 'Not an image' : '$notImages files were not images',
error: true,
count: notImages,
);
}
}
@ -104,5 +108,7 @@ Future<void> attachFiles(
}
}
target.insert([for (final attachment in attached) attachment.markdown]);
if (failure != null && context.mounted) showError(context, failure);
if (failure != null && context.mounted) {
showCue(context, Icons.cloud_off, failure, error: true);
}
}

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/cue_snack.dart';
import 'package:todo/ui/images/file_opening.dart';
import 'package:todo/ui/images/image_embed.dart';
@ -51,7 +52,9 @@ class _FileEmbedState extends State<FileEmbed> {
Future<void> _open(ImageSource source) async {
final failure = await widget.opening(source, widget.attachment.name);
if (failure != null && mounted) showError(context, failure);
if (failure != null && mounted) {
showCue(context, Icons.block, failure, error: true);
}
}
@override
@ -73,9 +76,10 @@ class _FileEmbedState extends State<FileEmbed> {
style: widget.compact ? theme.textTheme.bodySmall : null,
);
final pending = store.isPending(attachment.ref)
? const Tooltip(
message: kImageWaitingToUpload,
child: Icon(Icons.cloud_upload_outlined, size: 18),
? const Icon(
Icons.cloud_upload_outlined,
size: 18,
semanticLabel: kImageWaitingToUpload,
)
: null;
final content = widget.compact
@ -104,7 +108,11 @@ class _FileEmbedState extends State<FileEmbed> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
name,
Text(detail, style: theme.textTheme.bodySmall),
// Not downloaded: a download glyph; else the size.
if (size == null)
const Icon(Icons.cloud_download_outlined, size: 16)
else
Text(detail, style: theme.textTheme.bodySmall),
],
),
),

View File

@ -54,9 +54,10 @@ class ImageEmbed extends StatelessWidget {
const Positioned(
right: 4,
top: 4,
child: Tooltip(
message: kImageWaitingToUpload,
child: Icon(Icons.cloud_upload_outlined, size: 18),
child: Icon(
Icons.cloud_upload_outlined,
size: 18,
semanticLabel: kImageWaitingToUpload,
),
),
],
@ -87,7 +88,7 @@ Widget imageFor(ImageSource source, {required BoxFit fit}) {
};
}
/// Grey box with [kImageNotDownloaded].
/// Grey box with a download glyph, spoken as [kImageNotDownloaded].
class ImagePlaceholder extends StatelessWidget {
/// Creates the placeholder.
const ImagePlaceholder({super.key});
@ -100,10 +101,10 @@ class ImagePlaceholder extends StatelessWidget {
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
kImageNotDownloaded,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
child: Icon(
Icons.cloud_download_outlined,
color: theme.colorScheme.outline,
semanticLabel: kImageNotDownloaded,
),
),
),

View File

@ -101,7 +101,7 @@ class DateRangeField extends StatelessWidget {
onSelected: (p) => _select(context, p),
itemBuilder: (_) => [
for (final p in _Preset.values)
PopupMenuItem(value: p, child: Text(p.label)),
PopupMenuItem(value: p, child: p.glyph()),
],
child: InputDecorator(
decoration: InputDecoration(
@ -114,10 +114,9 @@ class DateRangeField extends StatelessWidget {
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
child: Text(
current == _Preset.custom ? _rangeLabel() : current.label,
style: theme.textTheme.bodyMedium,
),
child: current == _Preset.custom
? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
: current.glyph(),
),
);
}
@ -142,8 +141,26 @@ enum _Preset {
const _Preset(this.label, this.days);
/// Spoken name only; what is painted is [glyph].
final String label;
final int? days;
/// "Any" is an infinity sign, "today" the today-calendar, a span its day
/// count beside a range-calendar, "custom" the edit-calendar.
Widget glyph() => Semantics(
label: label,
excludeSemantics: true,
container: true,
child: switch (this) {
any => const Icon(Icons.all_inclusive),
today => const Icon(Icons.today),
custom => const Icon(Icons.edit_calendar),
week || month => Row(
mainAxisSize: MainAxisSize.min,
children: [const Icon(Icons.date_range), Text(' $days')],
),
},
);
static const List<_Preset> dayPresets = [today, week, month];
}

View File

@ -1,8 +1,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/cue_snack.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor_chrome.dart';
@ -127,9 +127,12 @@ class _NoteEditorState extends State<NoteEditor> {
case NoteEditorMode.guided:
// raw -> guided: only if the edited text still fits the template.
if (!_doc.tryAdoptGuided()) {
showError(
// The mode toggle staying on raw is the second channel.
showCue(
context,
Icons.rule,
"Text doesn't match the template — staying in raw",
error: true,
);
return;
}

View File

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/note_editor_mode.dart';
import 'package:todo/ui/template_glyphs.dart';
/// Template dropdown above a segmented View / Guided / Raw selector.
class NoteEditorChrome extends StatelessWidget {
@ -50,7 +51,7 @@ class NoteEditorChrome extends StatelessWidget {
),
items: [
for (final t in NoteTemplate.all)
DropdownMenuItem(value: t.id, child: Text(t.label)),
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
],
onChanged: (id) {
if (id == null) return;

View File

@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/motion.dart';
import 'package:todo/ui/template_glyphs.dart';
/// One step of the guided editor: label, guidance, input, and navigation.
class NoteEditorStepPage extends StatelessWidget {
@ -95,18 +96,14 @@ class NoteEditorStepPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section labels are template content (they land in the note);
// the title step has none, so it gets a glyph, not a word.
if (section.isTitle)
const Icon(Icons.title, semanticLabel: 'title')
else
Text(section.label, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
section.helper,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
// A glyph per section; its label (the note's `## heading`) and
// the helper sentence are spoken only.
Icon(
sectionIcon(section),
color: theme.colorScheme.primary,
semanticLabel: section.isTitle
? section.helper
: '${section.label}: ${section.helper}',
),
const SizedBox(height: 12),
],
@ -124,9 +121,10 @@ class NoteEditorStepPage extends StatelessWidget {
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
hintText: section.hint,
border: const OutlineInputBorder(),
// No hintText: a hint is painted text. The section glyph
// above already names the field.
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => onChanged(),

View File

@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/template_glyphs.dart';
/// Asks for a priority, then a template, then hands both back via [onStart].
class NoteEditorWizard extends StatefulWidget {
@ -125,7 +126,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
),
items: [
for (final t in templates)
DropdownMenuItem(value: t.id, child: Text(t.label)),
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
],
onChanged: (id) {
if (id == null) return;

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/enum_glyphs.dart';
/// Danger for high, the accent for medium, muted for low.
Color priorityColor(Priority p, ColorScheme scheme) => switch (p) {
@ -14,6 +15,9 @@ Color priorityColor(Priority p, ColorScheme scheme) => switch (p) {
Priority.low => scheme.outline,
};
/// Painted in place of an empty note's title: a mark, not a word.
const String kEmptyTitle = '…';
/// The glyph standing for [s] wherever a status is shown without words.
IconData statusIcon(Status s) => switch (s) {
Status.todo => Icons.radio_button_unchecked,
@ -85,7 +89,7 @@ class NoteGridTile extends StatelessWidget {
children: [
Expanded(
child: Text(
title.isEmpty ? '(empty)' : title,
title.isEmpty ? kEmptyTitle : title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
@ -115,25 +119,20 @@ class NoteGridTile extends StatelessWidget {
),
Row(
children: [
// Shape + the tile's accent border carry status/priority;
// the enum words survive only as spoken labels.
Icon(
statusIcon(note.status),
size: 14,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: AppSpacing.xs),
Flexible(
child: Text(
note.status.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: cue,
),
semanticLabel: note.status.label,
),
const Spacer(),
Text(
note.priority.label,
maxLines: 1,
style: cue?.copyWith(color: accentFor(scheme)),
Icon(
priorityIcon(note.priority),
size: 14,
color: accentFor(scheme),
semanticLabel: note.priority.label,
),
],
),

View File

@ -39,12 +39,25 @@ class NoteSheetDetails extends StatelessWidget {
Text(body, style: theme.bodyMedium),
],
const SizedBox(height: 12),
Text(
'Edited ${_stamp(note.updatedAt)}'
' · Created ${_stamp(note.createdAt)}',
style: theme.bodySmall,
Wrap(
spacing: 12,
children: [
_dated(Icons.history, 'Edited', note.updatedAt, theme.bodySmall),
_dated(Icons.add, 'Created', note.createdAt, theme.bodySmall),
],
),
],
);
}
/// A glyph (edited = history, created = plus) before its timestamp.
Widget _dated(IconData icon, String spoken, DateTime t, TextStyle? style) =>
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, semanticLabel: spoken),
const SizedBox(width: 4),
Text(_stamp(t), style: style),
],
);
}

View File

@ -12,20 +12,27 @@ class NoteTaskDetails extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
// A glyph per field in place of its written name (kept spoken only);
// the values are content and stay as typed.
final rows = [
('Repo', note.repo),
('Done check', note.doneCheck),
('By', note.by),
('Evidence', note.evidence),
].where((row) => row.$2 != null);
(Icons.folder_outlined, 'Repo', note.repo),
(Icons.fact_check_outlined, 'Done check', note.doneCheck),
(Icons.person_outline, 'By', note.by),
(Icons.verified_outlined, 'Evidence', note.evidence),
].where((row) => row.$3 != null);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows) ...[
for (final (icon, label, value) in rows) ...[
const SizedBox(height: 12),
Text(label, style: theme.labelLarge),
const SizedBox(height: 4),
Text(value!, style: theme.bodyMedium),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, semanticLabel: label),
const SizedBox(width: 8),
Expanded(child: Text(value!, style: theme.bodyMedium)),
],
),
],
],
);

View File

@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_grid_tile.dart';
/// One row in the notes list: title, metadata line, and an actions button.
class NoteTile extends StatelessWidget {
@ -37,24 +39,17 @@ class NoteTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final firstLine = noteTitle(note.text);
// Every note has a status and a priority now, so both are always shown.
final meta = <String>[
note.status.label,
note.priority.label,
'edited ${_relative(note.updatedAt)}',
?note.repoName,
].join(' · ');
return ListTile(
selected: selected,
leading: selected
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
: null,
title: Text(
firstLine.isEmpty ? '(empty)' : firstLine,
firstLine.isEmpty ? kEmptyTitle : firstLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(meta),
subtitle: _meta(context),
trailing: IconButton(
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
onPressed: onActions,
@ -64,14 +59,28 @@ class NoteTile extends StatelessWidget {
);
}
/// Compact relative time like "2m ago" for the list subtitle.
String _relative(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inMinutes < 1) return 'just now';
if (d.inHours < 1) return '${d.inMinutes}m ago';
if (d.inDays < 1) return '${d.inHours}h ago';
return '${d.inDays}d ago';
}
/// Status and priority glyphs, the edit age, and the repo (content).
/// Every note has a status and a priority, so both are always shown.
Widget _meta(BuildContext context) => Row(
children: [
statusGlyph(note.status, size: 16),
const SizedBox(width: 6),
priorityGlyph(context, note.priority, size: 16),
const SizedBox(width: 6),
const Icon(Icons.history, size: 16, semanticLabel: 'Edited'),
Text(relativeAge(note.updatedAt)),
if (note.repoName case final repo?) ...[
const SizedBox(width: 6),
Flexible(child: Text(repo, overflow: TextOverflow.ellipsis)),
],
],
);
}
/// Bottom sheet for editing one note's priority/status or deleting it.
/// Compact age like "2m" / "3h" / "4d": a number plus a unit symbol.
String relativeAge(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inHours < 1) return '${d.inMinutes}m';
if (d.inDays < 1) return '${d.inHours}h';
return '${d.inDays}d';
}

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/empty_notes_cue.dart';
import 'package:todo/ui/note_selection.dart';
import 'package:todo/ui/notes_assign_flow.dart';
import 'package:todo/ui/notes_filter_sheet.dart';
@ -224,11 +225,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) {
return Center(
child: Text(
_filter.isEmpty
? 'No notes yet'
: 'No notes match these filters',
),
child: EmptyNotesCue(filtered: !_filter.isEmpty),
);
}
return NotesListBody(

49
lib/ui/status_cue.dart Normal file
View File

@ -0,0 +1,49 @@
/// A screen's latest result as a glyph in place of a status sentence.
library;
import 'package:flutter/material.dart';
/// One result: what to paint, whether it is a failure, and the old
/// sentence, kept as the spoken label only.
class StatusCue {
/// Creates a cue.
const StatusCue(this.icon, this.spoken, {this.error = false});
/// A success: a check glyph in the accent colour.
const StatusCue.ok(this.spoken) : icon = Icons.check_circle, error = false;
/// A failure: an error glyph in the danger hue.
const StatusCue.failed(this.spoken) : icon = Icons.error, error = true;
/// The glyph painted.
final IconData icon;
/// What a screen reader says.
final String spoken;
/// Whether this is a failure (danger hue) rather than a success.
final bool error;
}
/// Paints [cue] as a large glyph; colour is the second channel.
class StatusCueView extends StatelessWidget {
/// Creates the view.
const StatusCueView(this.cue, {super.key});
/// The result shown.
final StatusCue cue;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: Alignment.centerLeft,
child: Icon(
cue.icon,
size: 32,
color: cue.error ? scheme.error : scheme.primary,
semanticLabel: cue.spoken,
),
);
}
}

View File

@ -0,0 +1,30 @@
/// Glyphs standing for note templates and their sections in the editor.
///
/// The section labels stay in `note_template.dart`: they are the note's own
/// `## heading` format, read by `parse()`, BACKLOG.md and the CLI. Only what
/// the editor paints around them is a glyph; the label and helper are spoken.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart';
/// The glyph for template [t]: a structured form, or a blank page.
IconData templateIcon(NoteTemplate t) =>
t.isFreeform ? Icons.note_outlined : Icons.assignment_outlined;
/// [t] as its glyph, spoken as its label.
Widget templateGlyph(NoteTemplate t) =>
Icon(templateIcon(t), semanticLabel: t.label);
/// The glyph heading a guided step, keyed by the section's stable `key`.
IconData sectionIcon(TemplateSection s) => switch (s.key) {
'title' => Icons.title,
'what' => Icons.lightbulb_outline,
'where' => Icons.place_outlined,
'must' => Icons.rule,
'done' => Icons.emoji_events_outlined,
'verify' => Icons.devices,
'refs' => Icons.menu_book_outlined,
_ => Icons.notes,
};

View File

@ -0,0 +1,55 @@
/// Framework strings the app paints without asking for them.
///
/// Material's own buttons (the app bar back arrow, a dialog's close, the
/// drawer, the overflow menu) carry tooltips from [MaterialLocalizations],
/// and a tooltip is painted on long-press and hover. kuhy's textgate rule
/// says nothing he sees has words he did not ask for, so these tooltips are
/// blanked: an empty tooltip is not built at all. Screen readers still get
/// the button's semantics from the icon itself.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// English Material strings with every button tooltip blanked.
class TextFreeMaterialLocalizations extends DefaultMaterialLocalizations {
/// Creates the localizations.
const TextFreeMaterialLocalizations();
@override
String get backButtonTooltip => '';
@override
String get closeButtonTooltip => '';
@override
String get openAppDrawerTooltip => '';
@override
String get showMenuTooltip => '';
@override
String get moreButtonTooltip => '';
@override
String get deleteButtonTooltip => '';
}
class _Delegate extends LocalizationsDelegate<MaterialLocalizations> {
const _Delegate();
@override
bool isSupported(Locale locale) => locale.languageCode == 'en';
@override
Future<MaterialLocalizations> load(Locale locale) =>
SynchronousFuture(const TextFreeMaterialLocalizations());
@override
bool shouldReload(_Delegate old) => false;
}
/// Pass as `MaterialApp.localizationsDelegates`.
const List<LocalizationsDelegate<dynamic>> kTextFreeLocalizations = [
_Delegate(),
];

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.73+1
version: 1.0.75+1
environment:
sdk: ^3.12.2

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets(
@ -140,7 +141,7 @@ void main() {
// Capture must never be interrupted by a snackbar…
expect(find.byType(SnackBar), findsNothing);
// …but the failure is no longer swallowed: the status line shows it.
expect(find.textContaining('Sync failed at'), findsOneWidget);
expect(find.textContaining('offline'), findsOneWidget);
expect(spoken('Sync failed at'), findsOneWidget);
expect(spoken('offline'), findsOneWidget);
});
}

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets('defaults to Raw, with Guided available via the entry wizard', (
@ -49,7 +50,7 @@ void main() {
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
findsOneWidget,
);
expect(find.textContaining('imperative'), findsOneWidget); // title helper
expect(spoken('imperative'), findsOneWidget); // title helper
});
testWidgets('saving the untouched template creates no note', (tester) async {
@ -101,12 +102,13 @@ void main() {
await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); // settings load + snackbar
await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// The behaviour that matters is the routing, not the wording: an
// unconfigured device is sent to settings to connect a backend. Asserting
// on the snackbar text tied this to one phrasing, and the snackbar is
// gone by the time settings has pushed anyway.
expect(find.textContaining('Connect Firebase'), findsWidgets);
expect(spoken('Connect Firebase'), findsWidgets);
});
testWidgets('the notes-list button navigates to the list screen', (

View File

@ -28,6 +28,7 @@ import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/github_mirror_screen.dart';
import 'package:todo/ui/settings_screen.dart';
import 'package:todo/ui/text_free_localizations.dart';
import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
@ -73,6 +74,8 @@ Future<FakeNoteRepository> pumpCapture(
debounce: Duration.zero,
);
final app = MaterialApp(
// As in main.dart: Material's own button tooltips are blanked.
localizationsDelegates: kTextFreeLocalizations,
home: CaptureScreen(
repository: repo,
appSettings:

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets(
@ -35,8 +36,8 @@ void main() {
await tester.pump();
// Only a failure is worth surfacing; a routine success is silent.
expect(find.textContaining('Synced at'), findsNothing);
expect(find.textContaining('merged 0 device(s)'), findsNothing);
expect(spoken('Synced at'), findsNothing);
expect(spoken('merged 0 device(s)'), findsNothing);
},
);
@ -53,8 +54,8 @@ void main() {
);
await tester.pump();
expect(find.textContaining('Sync failed at 12:00:00'), findsOneWidget);
expect(find.textContaining('rate limited'), findsOneWidget);
expect(spoken('Sync failed at 12:00:00'), findsOneWidget);
expect(spoken('rate limited'), findsOneWidget);
});
testWidgets('losing focus triggers a debounced auto-sync', (tester) async {

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets('Sync with a configured token runs the sync service', (
@ -39,14 +40,13 @@ void main() {
await tester.pump(); // setState(_syncing = true)
await tester.pump(); // service runs, snackbar scheduled
await tester.pump(); // snackbar builds
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// No firebaseFactory was injected, so this runs over GitHub alone --
// the status line must say so rather than reading identically to a
// Firebase-connected sync.
expect(
find.textContaining(
'Synced: GitHub-only (Firebase not connected) — merged 0 device',
),
spoken('Synced: GitHub-only (Firebase not connected) — merged 0 device'),
findsOneWidget,
);
});
@ -59,10 +59,11 @@ void main() {
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
expect(find.textContaining('Sync failed:'), findsOneWidget);
expect(find.textContaining('Sync failed at'), findsOneWidget);
expect(spoken('Sync failed:'), findsOneWidget);
expect(spoken('Sync failed at'), findsOneWidget);
});
testWidgets('manual sync reports peer files it could not decode', (
@ -89,9 +90,10 @@ void main() {
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(
find.textContaining('skipped 1 unreadable file(s)'),
spoken('skipped 1 unreadable file(s)'),
findsWidgets, // snackbar and status line both call it out
);
});
@ -120,7 +122,7 @@ void main() {
await tester.pumpAndSettle(); // save + pop transition back to Settings
expect(find.byType(SettingsScreen), findsOneWidget);
await tester.pageBack();
await tester.tap(find.byType(BackButton)); // no 'Back' tooltip to find
await tester.pumpAndSettle(); // pop transition back to capture
expect(find.byType(SettingsScreen), findsNothing); // back on capture
@ -154,7 +156,7 @@ void main() {
expect(find.bySemanticsLabel('Priority'), findsNothing);
expect(find.bySemanticsLabel('Status'), findsNothing);
// Routine status chatter is gone; only a badge count remains.
expect(find.textContaining('Autosaves as you type'), findsNothing);
expect(spoken('Autosaves as you type'), findsNothing);
expect(find.text('1'), findsOneWidget);
// The note still persisted normally underneath the simplified UI.
expect(find.text('A plain idea'), findsOneWidget);

View File

@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'github_mirror_harness.dart';
import 'text_free_guard.dart';
/// Stub launcher that records the URL instead of opening it, so `_openPage`
/// can be exercised without a real platform channel.
@ -80,7 +81,7 @@ void main() {
await tester.pump();
}
expect(find.textContaining('sync failed'), findsOneWidget);
expect(spoken('sync failed'), findsOneWidget);
});
testWidgets('Save persists the settings and closes the screen', (

View File

@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'github_mirror_harness.dart';
import 'text_free_guard.dart';
/// Stub launcher that records the URL instead of opening it, so `_openPage`
/// can be exercised without a real platform channel.
@ -45,10 +46,7 @@ void main() {
await pumpMirror(tester);
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump();
expect(
find.textContaining('Enter the OAuth App client id'),
findsOneWidget,
);
expect(spoken('Enter the OAuth App client id'), findsOneWidget);
});
testWidgets('Test connection reports a reachable repo', (tester) async {
@ -59,7 +57,7 @@ void main() {
await tester.pump(); // start
await tester.pump(); // resolve future + rebuild
expect(find.textContaining('reachable'), findsOneWidget);
expect(spoken('reachable'), findsOneWidget);
});
testWidgets('Test connection reports an inaccessible repo', (tester) async {
@ -70,7 +68,7 @@ void main() {
await tester.pump();
await tester.pump();
expect(find.textContaining('Could not access'), findsOneWidget);
expect(spoken('Could not access'), findsOneWidget);
});
testWidgets('Test connection surfaces a network error', (tester) async {
@ -81,7 +79,7 @@ void main() {
await tester.pump();
await tester.pump();
expect(find.textContaining('Error:'), findsOneWidget);
expect(spoken('Error:'), findsOneWidget);
});
testWidgets('device flow failure to start shows a message', (tester) async {
@ -101,7 +99,7 @@ void main() {
await tester.pump();
await tester.pump();
expect(find.textContaining('Could not start device flow'), findsOneWidget);
expect(spoken('Could not start device flow'), findsOneWidget);
});
testWidgets('device flow happy path saves the token', (tester) async {
@ -145,6 +143,6 @@ void main() {
await tester.pump();
}
expect(find.textContaining('Connected and synced'), findsOneWidget);
expect(spoken('Connected and synced'), findsOneWidget);
});
}

View File

@ -4,8 +4,10 @@ import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor.dart';
import 'package:todo/ui/template_glyphs.dart';
import 'note_editor_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets('guided: typing the title emits an assembled # heading', (
@ -49,14 +51,13 @@ void main() {
for (var i = 0; i < spec.sections.length; i++) {
final section = spec.sections[i];
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
// Each step paints its section glyph; label + helper are spoken.
expect(
section.isTitle
? find.bySemanticsLabel('title')
: find.text(section.label),
findsWidgets,
find.byIcon(sectionIcon(section)),
findsOneWidget,
reason: 'step ${i + 1} should show "${section.label}"',
);
expect(find.text(section.helper), findsOneWidget);
expect(spoken(section.helper), findsOneWidget);
if (i < spec.sections.length - 1) {
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
@ -76,7 +77,7 @@ void main() {
initialTemplate: spec,
initialMode: NoteEditorMode.raw,
);
expect(find.text('LLM design spec'), findsOneWidget);
expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
await tester.enterText(
find.byType(TextField).first,
@ -85,7 +86,7 @@ void main() {
await tester.pump();
// Freeform now, so there is no seven-step stepper to walk for a link.
expect(find.text('Blank'), findsOneWidget);
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
expect(find.bySemanticsLabel('Guided'), findsNothing);
// Adding prose puts the spec template back — lossless in both directions.
@ -94,7 +95,7 @@ void main() {
'https://example.com/x — port this to Dart',
);
await tester.pump();
expect(find.text('LLM design spec'), findsOneWidget);
expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
});
@ -107,16 +108,16 @@ void main() {
initialMode: NoteEditorMode.raw,
);
await tester.tap(find.text('LLM design spec'));
await tester.tap(find.byIcon(Icons.assignment_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('Blank').last);
await tester.tap(find.byIcon(Icons.note_outlined).last);
await tester.pumpAndSettle();
expect(find.text('Blank'), findsOneWidget);
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
// Prose would normally re-select the spec template; an explicit pick wins.
await tester.enterText(find.byType(TextField).first, 'build a thing');
await tester.pump();
expect(find.text('Blank'), findsOneWidget);
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
});
testWidgets('switching to View renders the note as Markdown', (tester) async {

View File

@ -6,6 +6,7 @@ import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor.dart';
import 'note_editor_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets(
@ -40,8 +41,9 @@ void main() {
// Structured template + non-conforming raw text → switching is refused.
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(find.textContaining("doesn't match the template"), findsOneWidget);
expect(spoken("doesn't match the template"), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
});
@ -97,9 +99,11 @@ void main() {
findsOneWidget,
); // structured: Guided offered
await tester.tap(find.text('LLM design spec').first); // open the menu
await tester.tap(
find.byIcon(Icons.assignment_outlined).first,
); // open the menu
await tester.pumpAndSettle();
await tester.tap(find.text('Blank').last);
await tester.tap(find.byIcon(Icons.note_outlined).last);
await tester.pumpAndSettle();
// Freeform now: Guided is no longer offered.

View File

@ -6,6 +6,7 @@ import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor.dart';
import 'note_editor_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets(
@ -61,12 +62,14 @@ void main() {
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
await tester.tap(find.text('LLM design spec').first); // open the menu
await tester.tap(
find.byIcon(Icons.assignment_outlined).first,
); // open the menu
await tester.pumpAndSettle();
expect(find.text('Blank'), findsNothing);
expect(find.byIcon(Icons.note_outlined), findsNothing);
// Select the (only) offered template, exercising the dropdown's onChanged.
await tester.tap(find.text('LLM design spec').last);
await tester.tap(find.byIcon(Icons.assignment_outlined).last);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Step 2 of 2'), findsOneWidget);
});

View File

@ -7,6 +7,8 @@ import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
import 'notes_list_harness.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/empty_notes_cue.dart';
void _phone(WidgetTester t) {
t.view
@ -53,10 +55,10 @@ void main() {
);
expect(find.text('Buy milk'), findsOneWidget);
expect(find.text('High'), findsOneWidget);
expect(find.text('In progress'), findsOneWidget);
expect(find.byIcon(priorityIcon(Priority.high)), findsOneWidget);
expect(find.byIcon(statusIcon(Status.inProgress)), findsOneWidget);
expect(find.textContaining('Two litres'), findsNothing);
expect(find.textContaining('edited'), findsNothing);
expect(find.byIcon(Icons.history), findsNothing);
expect(find.byTooltip('Quick actions'), findsNothing);
});
@ -93,7 +95,7 @@ void main() {
testWidgets('an empty note reads (empty)', (t) async {
_phone(t);
await pumpList(t, seed: [note('a', '')]);
expect(find.text('(empty)'), findsOneWidget);
expect(find.text(kEmptyTitle), findsOneWidget);
});
testWidgets('tap opens the note', (t) async {
@ -119,8 +121,14 @@ void main() {
await _openSheet(t, 'Buy milk');
expect(find.textContaining('Two litres of oat'), findsOneWidget);
expect(find.textContaining('Edited'), findsOneWidget);
expect(find.textContaining('Created'), findsOneWidget);
expect(find.byIcon(Icons.history), findsOneWidget);
expect(
find.descendant(
of: find.byType(BottomSheet),
matching: find.byIcon(Icons.add),
),
findsOneWidget,
);
await t.tap(find.bySemanticsLabel('In progress'));
await t.pump();
expect((await repo.listNotes()).single.status, Status.inProgress);
@ -134,7 +142,7 @@ void main() {
await _openSheet(t, 'Just a title');
expect(find.text('Just a title'), findsNWidgets(2));
expect(find.textContaining('Edited'), findsOneWidget);
expect(find.byIcon(Icons.history), findsOneWidget);
});
testWidgets('the sheet can delete a grid note', (t) async {
@ -154,7 +162,7 @@ void main() {
_phone(t);
final repo = await pumpList(t);
expect(find.textContaining('No notes'), findsOneWidget);
expect(find.byType(EmptyNotesCue), findsOneWidget);
expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress});
expect(t.widget<Badge>(find.byType(Badge)).isLabelVisible, isFalse);
});

View File

@ -7,13 +7,14 @@ import 'package:todo/ui/notes_list_screen.dart';
import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
import 'text_free_guard.dart';
/// Opens a date field's menu and picks [entry].
Future<void> pickRange(WidgetTester tester, String field, String entry) async {
await tester.tap(find.bySemanticsLabel(field));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text(entry).last);
await tester.tap(spoken(entry).last);
await tester.pump();
}
@ -68,10 +69,10 @@ void main() {
],
);
expect(find.textContaining('just now'), findsOneWidget);
expect(find.textContaining('5m ago'), findsOneWidget);
expect(find.textContaining('2h ago'), findsOneWidget);
expect(find.textContaining('3d ago'), findsOneWidget);
expect(find.text('0m'), findsOneWidget);
expect(find.text('5m'), findsOneWidget);
expect(find.text('2h'), findsOneWidget);
expect(find.text('3d'), findsOneWidget);
});
testWidgets('Clear all resets the filter sheet to the default', (

View File

@ -10,6 +10,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/notes_list_screen.dart';
import 'package:todo/ui/text_free_localizations.dart';
import 'fake_note_repository.dart';
@ -42,6 +43,8 @@ Future<FakeNoteRepository> pumpList(
addTearDown(settings.dispose);
await tester.pumpWidget(
MaterialApp(
// As in main.dart: Material's own button tooltips are blanked.
localizationsDelegates: kTextFreeLocalizations,
home: NotesListScreen(repository: repo, appSettings: settings),
),
);

View File

@ -53,15 +53,15 @@ void main() {
expect(find.text('make check'), findsOneWidget);
expect(find.text('claude'), findsOneWidget);
expect(find.text('abc 0'), findsOneWidget);
expect(find.text('Done check'), findsOneWidget);
expect(find.text('Evidence'), findsOneWidget);
expect(find.byIcon(Icons.fact_check_outlined), findsOneWidget);
expect(find.byIcon(Icons.verified_outlined), findsOneWidget);
expect(find.byType(TextField), findsNothing);
});
testWidgets('a plain idea shows no task fields', (tester) async {
await pumpActions(tester, note('i', 'An idea'));
expect(find.text('Repo'), findsNothing);
expect(find.text('Done check'), findsNothing);
expect(find.text('Evidence'), findsNothing);
expect(find.byIcon(Icons.folder_outlined), findsNothing);
expect(find.byIcon(Icons.fact_check_outlined), findsNothing);
expect(find.byIcon(Icons.verified_outlined), findsNothing);
});
}

View File

@ -7,6 +7,9 @@ import 'package:todo/ui/notes_list_screen.dart';
import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_grid_tile.dart';
import 'package:todo/ui/empty_notes_cue.dart';
void main() {
testWidgets('renders notes with a status · priority · time subtitle', (
@ -18,8 +21,8 @@ void main() {
);
expect(find.text('First note'), findsOneWidget);
expect(find.textContaining('To do'), findsOneWidget);
expect(find.textContaining('High'), findsOneWidget);
expect(find.byIcon(statusIcon(Status.todo)), findsOneWidget);
expect(find.byIcon(priorityIcon(Priority.high)), findsOneWidget);
});
testWidgets('defaults to hiding Done/Abandoned with no filter badge', (
@ -122,6 +125,6 @@ void main() {
await pumpList(tester); // no seed
// The default filter hides Done/Abandoned, so it's the "no match"
// variant rather than "No notes yet" — either way, an empty message.
expect(find.textContaining('No notes'), findsOneWidget);
expect(find.byType(EmptyNotesCue), findsOneWidget);
});
}

49
test/text_free_guard.dart Normal file
View File

@ -0,0 +1,49 @@
/// The "no words" guard: kuhy's textgate rule says nothing he sees carries
/// app-written text. This walks every painted string in the widget tree and
/// fails on any run of two letters that is not note content.
///
/// Deliberately NOT named `*_test.dart`: it is a helper with no `main()`.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
final RegExp _word = RegExp('[A-Za-z]{2,}');
/// Finds a node whose spoken (semantics) label contains [part] literally.
/// The old visible sentences live on as spoken labels, so tests that used
/// `find.textContaining` now look here.
Finder spoken(String part) =>
find.bySemanticsLabel(RegExp(RegExp.escape(part)));
/// Every string the tree paints: each [RichText] (which is what [Text] builds)
/// and every non-empty [Tooltip] message, since a tooltip is shown on
/// long-press. Text being typed lives in an [EditableText] and is the user's,
/// so it is never painted through a [RichText] and is not collected.
List<String> paintedStrings(WidgetTester tester) => [
for (final w in tester.widgetList<RichText>(find.byType(RichText)))
w.text.toPlainText(),
for (final t in tester.widgetList<Tooltip>(find.byType(Tooltip)))
if ((t.message ?? '').isNotEmpty) t.message!,
];
/// Fails if any painted string still holds a word once every [content]
/// string (note text, typed values: things kuhy wrote) is removed from it.
///
/// An empty tree is a failure too: a guard that saw nothing proves nothing.
void expectNoChromeWords(
WidgetTester tester, {
List<String> content = const [],
}) {
final painted = paintedStrings(tester);
expect(painted, isNotEmpty, reason: 'guard saw no painted strings');
final offenders = <String>[];
for (final s in painted) {
var rest = s;
for (final c in content) {
rest = rest.replaceAll(c, '');
}
if (_word.hasMatch(rest)) offenders.add(s);
}
expect(offenders, isEmpty, reason: 'app-written words are painted');
}

View File

@ -0,0 +1,108 @@
/// Textgate guard for the notes list and the sheets it opens: nothing
/// app-written is painted, only note content, numbers and glyphs.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'notes_list_harness.dart';
import 'text_free_guard.dart';
const _content = [
'Buy milk',
'Fix sync',
'kuhyx/todo-app',
'flutter test',
'claude',
'green run',
'todo-app',
];
final _task = Note(
id: 't',
text: 'Fix sync',
priority: Priority.high,
status: Status.inProgress,
createdAt: DateTime(2026, 6, 15, 9),
updatedAt: DateTime(2026, 6, 15, 9),
repo: 'kuhyx/todo-app',
doneCheck: 'flutter test',
by: 'claude',
evidence: 'green run',
);
void _size(WidgetTester t, Size s, double dpr) {
t.view
..physicalSize = s
..devicePixelRatio = dpr;
addTearDown(t.view.reset);
}
void _desktop(WidgetTester t) => _size(t, const Size(1600, 1000), 1);
void _phone(WidgetTester t) => _size(t, const Size(1080, 2400), 2.625);
Future<void> _settle(WidgetTester t) async {
await t.pump();
await t.pump(const Duration(milliseconds: 300));
}
void main() {
testWidgets('the empty list paints no words', (t) async {
_desktop(t);
await pumpList(t);
expectNoChromeWords(t);
});
testWidgets('the filtered-empty list paints no words', (t) async {
_desktop(t);
await pumpList(
t,
seed: [note('a', 'Buy milk', status: Status.done)],
applyFilter: true,
);
expectNoChromeWords(t);
});
testWidgets('the desktop list paints only note content', (t) async {
_desktop(t);
await pumpList(t, seed: [note('a', 'Buy milk'), note('e', ''), _task]);
expectNoChromeWords(t, content: _content);
});
testWidgets('the phone grid paints only note content', (t) async {
_phone(t);
await pumpList(t, seed: [note('a', 'Buy milk'), note('e', ''), _task]);
expectNoChromeWords(t, content: _content);
});
testWidgets('the filter sheet paints no words', (t) async {
_phone(t);
await pumpList(t, seed: [note('a', 'Buy milk')], applyFilter: true);
await t.tap(find.byIcon(Icons.filter_list));
await _settle(t);
expectNoChromeWords(t, content: _content);
});
testWidgets('an opened note paints only its content', (t) async {
_desktop(t);
await pumpList(t, seed: [_task]);
await t.tap(find.text('Fix sync'));
await _settle(t);
expectNoChromeWords(t, content: _content);
});
for (final (name, title) in [
('plain note', 'Buy milk'),
('task', 'Fix sync'),
]) {
testWidgets('the $name sheet paints only note content', (t) async {
_phone(t);
await pumpList(t, seed: [note('a', 'Buy milk'), _task]);
await t.longPress(find.text(title));
await _settle(t);
expectNoChromeWords(t, content: _content);
});
}
}

View File

@ -0,0 +1,106 @@
/// Textgate guard for the capture screen, the editor and the settings
/// screens: nothing app-written is painted, only typed content and numbers.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/cloud_images_settings.dart';
import 'package:todo/ui/note_editor_mode.dart';
import 'capture_screen_harness.dart';
import 'github_mirror_harness.dart';
import 'note_editor_harness.dart';
import 'settings_screen_harness.dart';
import 'text_free_guard.dart';
Future<void> _frames(WidgetTester t) async {
await t.pump();
await t.pump(const Duration(milliseconds: 300));
}
void main() {
testWidgets('the capture screen paints no words', (t) async {
await pumpCapture(t);
expectNoChromeWords(t);
});
testWidgets('a saved draft paints no words', (t) async {
await pumpCapture(t);
await t.enterText(find.byType(TextField), 'Buy milk');
await _frames(t);
expectNoChromeWords(t);
});
testWidgets('sync with nothing configured paints no words', (t) async {
await pumpCapture(t);
await t.tap(find.bySemanticsLabel('Sync'));
await _frames(t);
expectNoChromeWords(t);
});
testWidgets('a failed sync paints no words', (t) async {
await pumpCapture(
t,
prefs: configuredPrefs,
httpClient: MockClient((_) async => http.Response('boom', 500)),
);
await t.tap(find.bySemanticsLabel('Sync'));
await _frames(t);
expectNoChromeWords(t, content: const ['boom']);
});
testWidgets('the guided wizard and its steps paint no words', (t) async {
await pumpCapture(t);
await t.tap(find.bySemanticsLabel('Guided'));
await t.pump();
expectNoChromeWords(t);
await t.tap(find.bySemanticsLabel('Next'));
await t.pump();
expectNoChromeWords(t);
await t.tap(find.bySemanticsLabel('Start'));
await t.pump();
expectNoChromeWords(t);
await t.tap(find.bySemanticsLabel('Next'));
await _frames(t);
expectNoChromeWords(t);
});
testWidgets('a guided step paints no words', (t) async {
await pumpEditor(t, initialTemplate: NoteTemplate.llmDesignSpec);
expectNoChromeWords(t);
});
testWidgets('the raw editor paints no words', (t) async {
await pumpEditor(t, initialMode: NoteEditorMode.raw);
expectNoChromeWords(t);
});
testWidgets('settings paint no words', (t) async {
await pumpSettings(t);
expectNoChromeWords(t);
});
testWidgets('GitHub sync and its test result paint no words', (t) async {
await pumpMirror(
t,
httpClient: MockClient((_) async => http.Response('{}', 404)),
);
expectNoChromeWords(t, content: const ['kuhyx', 'syncs']);
await t.tap(find.bySemanticsLabel('Test connection'));
await _frames(t);
expectNoChromeWords(t, content: const ['kuhyx', 'syncs']);
});
for (final field in [true, false]) {
testWidgets('cloud images (field: $field) paint no words', (t) async {
await t.pumpWidget(
MaterialApp(home: CloudImagesScreen(showPasswordField: field)),
);
await t.pump();
expectNoChromeWords(t);
});
}
}

View File

@ -7,6 +7,7 @@ import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/ui/image_attach.dart';
import '../fake_image_store.dart';
import '../text_free_guard.dart';
void main() {
late FakeImageStore store;
@ -71,7 +72,8 @@ void main() {
);
await tester.pump();
expect(inserted.single, hasLength(1));
expect(find.text('full'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(spoken('full'), findsOneWidget);
});
testWidgets('nothing is shown once the screen closed mid-attach', (
@ -92,6 +94,6 @@ void main() {
gate.complete();
await done;
await tester.pump();
expect(find.text('full'), findsNothing);
expect(spoken('full'), findsNothing);
});
}

View File

@ -10,6 +10,7 @@ import 'package:todo/ui/images/attachment_strip.dart';
import '../capture_screen_harness.dart';
import '../fake_image_store.dart';
import '../text_free_guard.dart';
void main() {
testWidgets('📎 on an empty draft creates the note under the file id', (
@ -132,6 +133,7 @@ void main() {
(await repo.listNotes()).single.id,
store.attached.keys.single.noteId,
);
expect(find.text('Not an image'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(spoken('Not an image'), findsOneWidget);
});
}

View File

@ -66,7 +66,10 @@ void main() {
final store = FakeImageStore();
await pump(tester, store: store, client: answer(207));
await saveWith(tester, ' pw ');
expect(find.text('Saved — dufs accepted the login'), findsOneWidget);
expect(
find.bySemanticsLabel('Saved — dufs accepted the login'),
findsOneWidget,
);
expect(store.reconciles.single, isEmpty);
});
@ -77,7 +80,10 @@ void main() {
final store = FakeImageStore();
await pump(tester, store: store, client: answer(401));
await saveWith(tester, 'bad');
expect(find.text('Saved, but dufs rejected the password'), findsOneWidget);
expect(
find.bySemanticsLabel('Saved, but dufs rejected the password'),
findsOneWidget,
);
expect(store.reconciles, isEmpty);
});
@ -89,7 +95,10 @@ void main() {
client: MockClient((_) => throw const SocketException('down')),
);
await saveWith(tester, 'pw');
expect(find.text('Saved — dufs is unreachable right now'), findsOneWidget);
expect(
find.bySemanticsLabel('Saved — dufs is unreachable right now'),
findsOneWidget,
);
});
testWidgets('no keystore means nothing is saved', (tester) async {
@ -97,7 +106,7 @@ void main() {
await pump(tester, store: FakeImageStore(), client: answer(207));
await saveWith(tester, 'pw');
expect(
find.text('Could not save: the keystore is unavailable'),
find.bySemanticsLabel('Could not save: the keystore is unavailable'),
findsOneWidget,
);
});
@ -111,7 +120,7 @@ void main() {
find.bySemanticsLabel('Login: missing — create it on the PC with'),
findsOneWidget,
);
expect(find.text(kAddLoginCommand), findsOneWidget);
expect(find.bySemanticsLabel('Copy: $kAddLoginCommand'), findsOneWidget);
});
testWidgets('closing the screen mid-test is harmless', (tester) async {

View File

@ -8,6 +8,7 @@ import 'package:todo/ui/images/file_embed.dart';
import 'package:todo/ui/images/image_embed.dart';
import '../fake_image_store.dart';
import '../text_free_guard.dart';
void main() {
final pdf = AttachmentRef.forFile('n1', 'a.pdf');
@ -57,13 +58,14 @@ void main() {
await pump(tester, embed());
await tester.tap(find.text('Raport.pdf'));
await tester.pump();
expect(find.text(failure!), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(spoken(failure!), findsOneWidget);
});
testWidgets('a file not here yet says so and does not open', (tester) async {
store.missing.add(pdf);
await pump(tester, embed());
expect(find.text(kImageNotDownloaded), findsOneWidget);
expect(find.byIcon(Icons.cloud_download_outlined), findsOneWidget);
await tester.tap(find.text('Raport.pdf'));
await tester.pump();
expect(opened, isEmpty);
@ -74,15 +76,15 @@ void main() {
) async {
store.pending.add(pdf);
await pump(tester, embed());
expect(find.byTooltip(kImageWaitingToUpload), findsOneWidget);
expect(find.byIcon(Icons.cloud_upload_outlined), findsOneWidget);
await pump(tester, embed(compact: true));
expect(find.byTooltip(kImageWaitingToUpload), findsOneWidget);
expect(find.byIcon(Icons.cloud_upload_outlined), findsOneWidget);
});
testWidgets('the size is asked again when the store changes', (tester) async {
store.missing.add(pdf);
await pump(tester, embed());
expect(find.text(kImageNotDownloaded), findsOneWidget);
expect(find.byIcon(Icons.cloud_download_outlined), findsOneWidget);
store
..missing.clear()
..changed();

View File

@ -8,6 +8,7 @@ import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/ui/image_attach.dart';
import '../fake_image_store.dart';
import '../text_free_guard.dart';
void main() {
test('the controller inserts only while an editor is bound', () {
@ -89,7 +90,8 @@ void main() {
],
);
await tester.pump();
expect(find.text('Not an image'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(spoken('Not an image'), findsOneWidget);
});
testWidgets('counts dropped non-images together', (tester) async {
@ -105,7 +107,8 @@ void main() {
rejected: 2,
);
await tester.pump();
expect(find.text('3 files were not images'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(spoken('3 files were not images'), findsOneWidget);
});
testWidgets('a store failure is shown and stops the batch', (tester) async {
@ -122,7 +125,8 @@ void main() {
],
);
await tester.pump();
expect(find.text('helper down'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(spoken('helper down'), findsOneWidget);
});
testWidgets('nothing is shown once the screen closed mid-attach', (

View File

@ -37,7 +37,7 @@ void main() {
testWidgets('a local image renders and opens full screen', (tester) async {
await pump(tester, ImageEmbed(ref: ref));
expect(find.byType(Image), findsOneWidget);
expect(find.byTooltip(kImageWaitingToUpload), findsNothing);
expect(find.byIcon(Icons.cloud_upload_outlined), findsNothing);
await tester.tap(find.byType(ImageEmbed));
await tester.pumpAndSettle();
expect(find.byType(ImageViewerScreen), findsOneWidget);
@ -47,7 +47,7 @@ void main() {
testWidgets('a queued image carries the upload badge', (tester) async {
store.pending.add(ref);
await pump(tester, ImageEmbed(ref: ref));
expect(find.byTooltip(kImageWaitingToUpload), findsOneWidget);
expect(find.byIcon(Icons.cloud_upload_outlined), findsOneWidget);
});
testWidgets('a missing image is a placeholder and not tappable', (
@ -55,7 +55,7 @@ void main() {
) async {
store.missing.add(ref);
await pump(tester, ImageEmbed(ref: ref));
expect(find.text(kImageNotDownloaded), findsOneWidget);
expect(find.byIcon(Icons.cloud_download_outlined), findsOneWidget);
await tester.tap(find.byType(ImageEmbed));
await tester.pumpAndSettle();
expect(find.byType(ImageViewerScreen), findsNothing);
@ -70,7 +70,7 @@ void main() {
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text(kImageNotDownloaded), findsOneWidget);
expect(find.byIcon(Icons.cloud_download_outlined), findsOneWidget);
store.network = true;
store.changed();
await tester.pump();
@ -78,7 +78,7 @@ void main() {
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text(kImageNotDownloaded), findsOneWidget);
expect(find.byIcon(Icons.cloud_download_outlined), findsOneWidget);
});
testWidgets('the strip shows one thumbnail per linked image', (tester) async {