mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 13:27:46 +01:00
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:
parent
091e68a2d9
commit
5ea7ba7b73
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
58
lib/ui/cue_snack.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
23
lib/ui/empty_notes_cue.dart
Normal file
23
lib/ui/empty_notes_cue.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@ -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!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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
49
lib/ui/status_cue.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
30
lib/ui/template_glyphs.dart
Normal file
30
lib/ui/template_glyphs.dart
Normal 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,
|
||||
};
|
||||
55
lib/ui/text_free_localizations.dart
Normal file
55
lib/ui/text_free_localizations.dart
Normal 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(),
|
||||
];
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
49
test/text_free_guard.dart
Normal 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');
|
||||
}
|
||||
108
test/text_free_list_test.dart
Normal file
108
test/text_free_list_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
106
test/text_free_screens_test.dart
Normal file
106
test/text_free_screens_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user