diff --git a/lib/main.dart b/lib/main.dart index cec9509..1700c4d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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( diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 0416c34..45acab1 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -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 Future _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 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); } diff --git a/lib/ui/capture_screen_sync.dart b/lib/ui/capture_screen_sync.dart index bfe8c1e..2d25507 100644 --- a/lib/ui/capture_screen_sync.dart +++ b/lib/ui/capture_screen_sync.dart @@ -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 diff --git a/lib/ui/capture_status_footer.dart b/lib/ui/capture_status_footer.dart index 6008b36..52755b1 100644 --- a/lib/ui/capture_status_footer.dart +++ b/lib/ui/capture_status_footer.dart @@ -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( 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)), + ], + ), + ); } diff --git a/lib/ui/capture_sync_runner.dart b/lib/ui/capture_sync_runner.dart index 284cf82..e8d27d5 100644 --- a/lib/ui/capture_sync_runner.dart +++ b/lib/ui/capture_sync_runner.dart @@ -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()); diff --git a/lib/ui/cloud_images_settings.dart b/lib/ui/cloud_images_settings.dart index 2527fd3..962b4cf 100644 --- a/lib/ui/cloud_images_settings.dart +++ b/lib/ui/cloud_images_settings.dart @@ -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 { final _password = TextEditingController(); ImageStoreStatus? _status; - String? _checkResult; + StatusCue? _checkResult; bool _checking = false; @override @@ -97,7 +99,11 @@ class _CloudImagesScreenState extends State { 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 { ); 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 { ), 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( diff --git a/lib/ui/cue_snack.dart b/lib/ui/cue_snack.dart new file mode 100644 index 0000000..cf60f37 --- /dev/null +++ b/lib/ui/cue_snack.dart @@ -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), + ), + ), + ); +} diff --git a/lib/ui/empty_notes_cue.dart b/lib/ui/empty_notes_cue.dart new file mode 100644 index 0000000..babd913 --- /dev/null +++ b/lib/ui/empty_notes_cue.dart @@ -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', + ); +} diff --git a/lib/ui/github_mirror_screen.dart b/lib/ui/github_mirror_screen.dart index c3fbb34..0d2f642 100644 --- a/lib/ui/github_mirror_screen.dart +++ b/lib/ui/github_mirror_screen.dart @@ -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 { ); bool _testing = false; - String? _status; + StatusCue? _status; @override void dispose() { @@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State { Future _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 { 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 _syncAfterConnect() async { final s = _current; try { @@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State { 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 _test() async { setState(() { _testing = true; @@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State { ); 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 { 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 { ), if (_status != null) ...[ const SizedBox(height: 16), - Text(_status!, style: Theme.of(context).textTheme.bodyMedium), + StatusCueView(_status!), ], ], ), diff --git a/lib/ui/image_attach.dart b/lib/ui/image_attach.dart index dcfdf18..756455a 100644 --- a/lib/ui/image_attach.dart +++ b/lib/ui/image_attach.dart @@ -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 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 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); + } } diff --git a/lib/ui/images/file_embed.dart b/lib/ui/images/file_embed.dart index edc3a17..77d5a27 100644 --- a/lib/ui/images/file_embed.dart +++ b/lib/ui/images/file_embed.dart @@ -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 { Future _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 { 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 { 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), ], ), ), diff --git a/lib/ui/images/image_embed.dart b/lib/ui/images/image_embed.dart index dc075a2..630969b 100644 --- a/lib/ui/images/image_embed.dart +++ b/lib/ui/images/image_embed.dart @@ -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, ), ), ), diff --git a/lib/ui/note_date_range_field.dart b/lib/ui/note_date_range_field.dart index b0e27c1..31a26c8 100644 --- a/lib/ui/note_date_range_field.dart +++ b/lib/ui/note_date_range_field.dart @@ -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]; } diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index e9ecb45..581313a 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -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 { 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; } diff --git a/lib/ui/note_editor_chrome.dart b/lib/ui/note_editor_chrome.dart index 2c36dd6..32cad6e 100644 --- a/lib/ui/note_editor_chrome.dart +++ b/lib/ui/note_editor_chrome.dart @@ -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; diff --git a/lib/ui/note_editor_step_page.dart b/lib/ui/note_editor_step_page.dart index b170f3d..7365f75 100644 --- a/lib/ui/note_editor_step_page.dart +++ b/lib/ui/note_editor_step_page.dart @@ -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(), diff --git a/lib/ui/note_editor_wizard.dart b/lib/ui/note_editor_wizard.dart index b86aebc..108e309 100644 --- a/lib/ui/note_editor_wizard.dart +++ b/lib/ui/note_editor_wizard.dart @@ -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 { ), 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; diff --git a/lib/ui/note_grid_tile.dart b/lib/ui/note_grid_tile.dart index ecb0d3b..029c8c3 100644 --- a/lib/ui/note_grid_tile.dart +++ b/lib/ui/note_grid_tile.dart @@ -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, ), ], ), diff --git a/lib/ui/note_sheet_details.dart b/lib/ui/note_sheet_details.dart index 5494a4d..c2862f3 100644 --- a/lib/ui/note_sheet_details.dart +++ b/lib/ui/note_sheet_details.dart @@ -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), + ], + ); } diff --git a/lib/ui/note_task_details.dart b/lib/ui/note_task_details.dart index 148536f..30d83b0 100644 --- a/lib/ui/note_task_details.dart +++ b/lib/ui/note_task_details.dart @@ -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)), + ], + ), ], ], ); diff --git a/lib/ui/note_tile.dart b/lib/ui/note_tile.dart index 9f514f8..b2c34e7 100644 --- a/lib/ui/note_tile.dart +++ b/lib/ui/note_tile.dart @@ -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 = [ - 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'; +} diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index be5b8eb..f48be14 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -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 { final notes = snapshot.data ?? const []; if (notes.isEmpty) { return Center( - child: Text( - _filter.isEmpty - ? 'No notes yet' - : 'No notes match these filters', - ), + child: EmptyNotesCue(filtered: !_filter.isEmpty), ); } return NotesListBody( diff --git a/lib/ui/status_cue.dart b/lib/ui/status_cue.dart new file mode 100644 index 0000000..de0f342 --- /dev/null +++ b/lib/ui/status_cue.dart @@ -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, + ), + ); + } +} diff --git a/lib/ui/template_glyphs.dart b/lib/ui/template_glyphs.dart new file mode 100644 index 0000000..5c391ba --- /dev/null +++ b/lib/ui/template_glyphs.dart @@ -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, +}; diff --git a/lib/ui/text_free_localizations.dart b/lib/ui/text_free_localizations.dart new file mode 100644 index 0000000..1da01e8 --- /dev/null +++ b/lib/ui/text_free_localizations.dart @@ -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 { + const _Delegate(); + + @override + bool isSupported(Locale locale) => locale.languageCode == 'en'; + + @override + Future load(Locale locale) => + SynchronousFuture(const TextFreeMaterialLocalizations()); + + @override + bool shouldReload(_Delegate old) => false; +} + +/// Pass as `MaterialApp.localizationsDelegates`. +const List> kTextFreeLocalizations = [ + _Delegate(), +]; diff --git a/pubspec.yaml b/pubspec.yaml index fdeee76..d70564a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/capture_screen_advanced_test.dart b/test/capture_screen_advanced_test.dart index ab154a7..ac11670 100644 --- a/test/capture_screen_advanced_test.dart +++ b/test/capture_screen_advanced_test.dart @@ -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); }); } diff --git a/test/capture_screen_editing_test.dart b/test/capture_screen_editing_test.dart index 149eb2b..827ea9a 100644 --- a/test/capture_screen_editing_test.dart +++ b/test/capture_screen_editing_test.dart @@ -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', ( diff --git a/test/capture_screen_harness.dart b/test/capture_screen_harness.dart index 74faf71..490cbfb 100644 --- a/test/capture_screen_harness.dart +++ b/test/capture_screen_harness.dart @@ -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 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: diff --git a/test/capture_screen_lifecycle_test.dart b/test/capture_screen_lifecycle_test.dart index 0408985..c2b30c4 100644 --- a/test/capture_screen_lifecycle_test.dart +++ b/test/capture_screen_lifecycle_test.dart @@ -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 { diff --git a/test/capture_screen_sync_test.dart b/test/capture_screen_sync_test.dart index 7562db6..c5d24f6 100644 --- a/test/capture_screen_sync_test.dart +++ b/test/capture_screen_sync_test.dart @@ -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); diff --git a/test/github_mirror_device_flow_test.dart b/test/github_mirror_device_flow_test.dart index b788066..b997a87 100644 --- a/test/github_mirror_device_flow_test.dart +++ b/test/github_mirror_device_flow_test.dart @@ -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', ( diff --git a/test/github_mirror_screen_test.dart b/test/github_mirror_screen_test.dart index 751913b..06c7f8d 100644 --- a/test/github_mirror_screen_test.dart +++ b/test/github_mirror_screen_test.dart @@ -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); }); } diff --git a/test/note_editor_guided_test.dart b/test/note_editor_guided_test.dart index 14c0ae1..def6726 100644 --- a/test/note_editor_guided_test.dart +++ b/test/note_editor_guided_test.dart @@ -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 { diff --git a/test/note_editor_modes_test.dart b/test/note_editor_modes_test.dart index b173dc8..b80110f 100644 --- a/test/note_editor_modes_test.dart +++ b/test/note_editor_modes_test.dart @@ -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. diff --git a/test/note_editor_wizard_test.dart b/test/note_editor_wizard_test.dart index 9542dda..6d8573d 100644 --- a/test/note_editor_wizard_test.dart +++ b/test/note_editor_wizard_test.dart @@ -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); }); diff --git a/test/notes_grid_test.dart b/test/notes_grid_test.dart index b0234b3..0b88431 100644 --- a/test/notes_grid_test.dart +++ b/test/notes_grid_test.dart @@ -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(find.byType(Badge)).isLabelVisible, isFalse); }); diff --git a/test/notes_list_filter_test.dart b/test/notes_list_filter_test.dart index f3fd2a4..4a5455f 100644 --- a/test/notes_list_filter_test.dart +++ b/test/notes_list_filter_test.dart @@ -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 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', ( diff --git a/test/notes_list_harness.dart b/test/notes_list_harness.dart index 75e4e3a..d535e06 100644 --- a/test/notes_list_harness.dart +++ b/test/notes_list_harness.dart @@ -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 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), ), ); diff --git a/test/notes_list_repo_filter_test.dart b/test/notes_list_repo_filter_test.dart index 056b094..e9cea46 100644 --- a/test/notes_list_repo_filter_test.dart +++ b/test/notes_list_repo_filter_test.dart @@ -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); }); } diff --git a/test/notes_list_screen_test.dart b/test/notes_list_screen_test.dart index f16cd23..e7a6b65 100644 --- a/test/notes_list_screen_test.dart +++ b/test/notes_list_screen_test.dart @@ -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); }); } diff --git a/test/text_free_guard.dart b/test/text_free_guard.dart new file mode 100644 index 0000000..fcaa170 --- /dev/null +++ b/test/text_free_guard.dart @@ -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 paintedStrings(WidgetTester tester) => [ + for (final w in tester.widgetList(find.byType(RichText))) + w.text.toPlainText(), + for (final t in tester.widgetList(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 content = const [], +}) { + final painted = paintedStrings(tester); + expect(painted, isNotEmpty, reason: 'guard saw no painted strings'); + final offenders = []; + 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'); +} diff --git a/test/text_free_list_test.dart b/test/text_free_list_test.dart new file mode 100644 index 0000000..e5a8154 --- /dev/null +++ b/test/text_free_list_test.dart @@ -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 _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); + }); + } +} diff --git a/test/text_free_screens_test.dart b/test/text_free_screens_test.dart new file mode 100644 index 0000000..9d6c98c --- /dev/null +++ b/test/text_free_screens_test.dart @@ -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 _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); + }); + } +} diff --git a/test/ui_images/attach_files_test.dart b/test/ui_images/attach_files_test.dart index bddbab9..bf9c8bf 100644 --- a/test/ui_images/attach_files_test.dart +++ b/test/ui_images/attach_files_test.dart @@ -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); }); } diff --git a/test/ui_images/capture_images_test.dart b/test/ui_images/capture_images_test.dart index 1cf6216..9080df7 100644 --- a/test/ui_images/capture_images_test.dart +++ b/test/ui_images/capture_images_test.dart @@ -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); }); } diff --git a/test/ui_images/cloud_images_settings_test.dart b/test/ui_images/cloud_images_settings_test.dart index 1cf403f..539a2e9 100644 --- a/test/ui_images/cloud_images_settings_test.dart +++ b/test/ui_images/cloud_images_settings_test.dart @@ -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 { diff --git a/test/ui_images/file_embed_test.dart b/test/ui_images/file_embed_test.dart index f2e8b10..9a88da1 100644 --- a/test/ui_images/file_embed_test.dart +++ b/test/ui_images/file_embed_test.dart @@ -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(); diff --git a/test/ui_images/image_attach_test.dart b/test/ui_images/image_attach_test.dart index c625c79..c927d0e 100644 --- a/test/ui_images/image_attach_test.dart +++ b/test/ui_images/image_attach_test.dart @@ -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', ( diff --git a/test/ui_images/image_embed_test.dart b/test/ui_images/image_embed_test.dart index e8fbec2..5f76dba 100644 --- a/test/ui_images/image_embed_test.dart +++ b/test/ui_images/image_embed_test.dart @@ -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.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.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 {