Text-free UI: glyph chips, icon-only actions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:02:25 +01:00
parent a30e7b34a9
commit 091e68a2d9
45 changed files with 717 additions and 490 deletions

View File

@ -44,18 +44,24 @@ Future<List<XFile>> pickAttachments(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Camera'),
leading: const Icon(
Icons.photo_camera_outlined,
semanticLabel: 'Camera',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Gallery'),
leading: const Icon(
Icons.photo_library_outlined,
semanticLabel: 'Gallery',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
),
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: const Text('File'),
leading: const Icon(
Icons.insert_drive_file_outlined,
semanticLabel: 'File',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
),
],
@ -102,8 +108,7 @@ class AttachButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Attach',
icon: const Icon(Icons.attach_file),
icon: const Icon(Icons.attach_file, semanticLabel: 'Attach'),
onPressed: () async {
final files = await (picking ?? pickAttachments)(context);
if (files.isNotEmpty) onFiles(files);

View File

@ -86,13 +86,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
// Not gated on advanced mode: attaching is basic capture.
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
IconButton(
tooltip: 'New note',
onPressed: onNewNote,
icon: const Icon(Icons.check),
icon: const Icon(Icons.check, semanticLabel: 'New note'),
),
if (advanced)
IconButton(
tooltip: 'Sync',
onPressed: syncing ? null : onSync,
icon: syncing
? const SizedBox(
@ -100,12 +98,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
: const Icon(Icons.sync, semanticLabel: 'Sync'),
),
IconButton(
tooltip: 'Notes',
onPressed: onOpenList,
icon: const Icon(Icons.list),
icon: const Icon(Icons.list, semanticLabel: 'Notes'),
),
// Live count of stored notes, proving local persistence. Sits directly
// right of the Notes button it belongs to, and carries no icon of its
@ -122,9 +119,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
),
CaptureConfirmMark(captured: captured),
IconButton(
tooltip: 'Settings',
onPressed: onOpenSettings,
icon: const Icon(Icons.settings),
icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
),
],
);

View File

@ -34,8 +34,7 @@ class CloudImagesTile extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Cloud images'),
subtitle: const Text('Where attached images and files upload (dufs)'),
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
@ -129,7 +128,9 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
Widget build(BuildContext context) {
final status = _status;
return Scaffold(
appBar: AppBar(title: const Text('Cloud images')),
appBar: AppBar(
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -138,13 +139,16 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
controller: _password,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password for the "todo" dufs login',
prefixIcon: Icon(
Icons.lock_outline,
semanticLabel: 'Password for the "todo" dufs login',
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _checking ? null : _saveAndTest,
child: const Text('Save & test'),
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
),
if (_checkResult case final result?) ...[
const SizedBox(height: 8),
@ -153,21 +157,45 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
const SizedBox(height: 16),
],
if (status != null) ...[
Text(
status.configured
// 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',
),
if (!status.configured) const SelectableText(kAddLoginCommand),
const SizedBox(height: 8),
Text('Waiting to upload: ${status.pending}'),
// Icon + the count itself: the number is the information.
Row(
children: [
const Icon(Icons.cloud_upload_outlined),
const SizedBox(width: 8),
Text(
'${status.pending}',
semanticsLabel: 'Waiting to upload: ${status.pending}',
),
],
),
],
const SizedBox(height: 16),
Text(
'Images are shrunk to 2560px JPEGs without location data before '
'they leave this device. Other files upload unchanged, at any '
'size.',
style: Theme.of(context).textTheme.bodySmall,
// Shrunk + no location, as glyphs; the sentence is spoken only.
const Row(
children: [
Icon(
Icons.photo_size_select_large,
semanticLabel:
'Images are shrunk to 2560px JPEGs without location data '
'before they leave this device. Other files upload '
'unchanged, at any size.',
),
SizedBox(width: 8),
Icon(Icons.location_off),
],
),
],
),

View File

@ -0,0 +1,58 @@
/// Icon-only confirmation before deleting a note.
///
/// Replaces design_system's `confirmDestructive` here because that dialog
/// paints its title, message and button labels as text. The safety contract
/// is kept: Cancel is autofocused (a reflexive Return keeps the note), Escape
/// and the barrier answer "no", and the confirm glyph wears the error colour.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Asks whether to delete a note; true only when the delete glyph is pressed.
Future<bool> confirmDeleteNote(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => const _ConfirmDeleteDialog(),
);
return confirmed ?? false;
}
class _ConfirmDeleteDialog extends StatelessWidget {
const _ConfirmDeleteDialog();
@override
Widget build(BuildContext context) {
final error = Theme.of(context).colorScheme.error;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
Navigator.of(context).pop(false),
},
child: AlertDialog(
// Spoken when the dialog opens; the old written body.
semanticLabel: 'This cannot be undone.',
title: Icon(
Icons.delete_forever,
size: 40,
color: error,
semanticLabel: 'Delete note?',
),
actionsAlignment: MainAxisAlignment.spaceEvenly,
actions: [
IconButton(
key: const ValueKey('confirm-cancel'),
autofocus: true,
onPressed: () => Navigator.of(context).pop(false),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
IconButton(
key: const ValueKey('confirm-delete'),
onPressed: () => Navigator.of(context).pop(true),
icon: Icon(Icons.delete, color: error, semanticLabel: 'Delete'),
),
],
),
);
}
}

105
lib/ui/enum_glyphs.dart Normal file
View File

@ -0,0 +1,105 @@
/// Glyphs for the Priority and Status enums, and the chip row that picks
/// them, so every picker shows the same shape + colour for the same value.
///
/// The enum's `label` survives only as the spoken (semantics) label.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
/// Shape channel for a priority: up for high, level for medium, down for low.
/// Colour ([priorityColor]) is the second channel.
IconData priorityIcon(Priority p) => switch (p) {
Priority.high => Icons.keyboard_double_arrow_up,
Priority.medium => Icons.drag_handle,
Priority.low => Icons.keyboard_double_arrow_down,
};
/// Heading glyph standing for "status" (matches the bulk-status button).
const IconData kStatusHeadingIcon = Icons.task_alt;
/// Heading glyph standing for "priority" (matches the bulk-priority button).
const IconData kPriorityHeadingIcon = Icons.flag_outlined;
/// [s] as its glyph, spoken as its label.
Widget statusGlyph(Status s, {double? size}) =>
Icon(statusIcon(s), size: size, semanticLabel: s.label);
/// [p] as its tinted glyph, spoken as its label.
Widget priorityGlyph(BuildContext context, Priority p, {double? size}) => Icon(
priorityIcon(p),
size: size,
color: priorityColor(p, Theme.of(context).colorScheme),
semanticLabel: p.label,
);
/// A heading glyph followed by one chip per value, each chip a glyph.
///
/// [multi] switches between FilterChips (toggle many) and ChoiceChips (pick
/// one); either way the selected chips are filled and checked.
class GlyphChips<T> extends StatelessWidget {
/// Creates a chip row for [values].
const GlyphChips({
required this.heading,
required this.semanticLabel,
required this.values,
required this.selected,
required this.glyphOf,
required this.onTap,
this.multi = false,
super.key,
});
/// Glyph shown in front of the chips in place of a written heading.
final IconData heading;
/// Spoken name of the row (the old written heading).
final String semanticLabel;
/// Every value offered.
final List<T> values;
/// Values currently selected.
final Set<T> selected;
/// Builds the glyph for one value.
final Widget Function(T) glyphOf;
/// Called with the tapped value.
final ValueChanged<T> onTap;
/// Whether this is a multi-select (filter) row.
final bool multi;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(heading, semanticLabel: semanticLabel),
const SizedBox(width: 12),
Expanded(
child: Wrap(
spacing: 8,
children: [
for (final v in values)
if (multi)
FilterChip(
label: glyphOf(v),
selected: selected.contains(v),
onSelected: (_) => onTap(v),
)
else
ChoiceChip(
label: glyphOf(v),
selected: selected.contains(v),
onSelected: (_) => onTap(v),
),
],
),
),
],
);
}
}

View File

@ -145,7 +145,12 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
appBar: AppBar(
title: const Icon(
Icons.cloud_sync,
semanticLabel: 'Advanced sync (GitHub)',
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -157,17 +162,16 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
child: IconButton.filled(
onPressed: _connectGitHub,
icon: const Icon(Icons.login),
label: const Text('Connect GitHub'),
icon: const Icon(Icons.login, semanticLabel: 'Connect GitHub'),
),
),
const SizedBox(height: 16),
TextField(
controller: _owner,
decoration: const InputDecoration(
labelText: 'GitHub owner',
prefixIcon: Icon(Icons.person, semanticLabel: 'GitHub owner'),
border: OutlineInputBorder(),
),
),
@ -175,7 +179,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
TextField(
controller: _repo,
decoration: const InputDecoration(
labelText: 'Repository name',
prefixIcon: Icon(Icons.folder, semanticLabel: 'Repository name'),
border: OutlineInputBorder(),
),
),
@ -183,8 +187,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
TextField(
controller: _clientId,
decoration: const InputDecoration(
labelText: 'OAuth App client id',
helperText: 'Leave as the baked-in default unless self-hosting',
prefixIcon: Icon(Icons.key, semanticLabel: 'OAuth App client id'),
border: OutlineInputBorder(),
),
),
@ -193,15 +196,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
controller: _token,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Access token (fallback)',
helperText: 'Contents: read/write on the sync repo only',
prefixIcon: Icon(
Icons.vpn_key,
semanticLabel: 'Access token (fallback)',
),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
IconButton.outlined(
onPressed: _testing ? null : _test,
icon: _testing
? const SizedBox(
@ -209,14 +214,15 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_tethering),
label: const Text('Test connection'),
: const Icon(
Icons.wifi_tethering,
semanticLabel: 'Test connection',
),
),
const SizedBox(width: 12),
FilledButton.icon(
IconButton.filled(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save'),
icon: const Icon(Icons.save, semanticLabel: 'Save'),
),
],
),

View File

@ -111,8 +111,10 @@ class _FileEmbedState extends State<FileEmbed> {
?pending,
],
);
// Spoken only: nothing paints this string.
final semanticLabel = 'Attached file ${attachment.name}, $detail';
return Semantics(
label: 'Attached file ${attachment.name}, $detail',
label: semanticLabel,
button: size != null,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,

View File

@ -33,8 +33,10 @@ class ImageEmbed extends StatelessWidget {
final store = ImageStoreScope.of(context);
final source = store.sourceFor(ref);
final picture = imageFor(source, fit: fit);
// Spoken only: nothing paints this string.
const semanticLabel = 'Attached image';
return Semantics(
label: 'Attached image',
label: semanticLabel,
button: source is! MissingImageSource,
child: GestureDetector(
onTap: source is MissingImageSource

View File

@ -6,10 +6,11 @@ library;
import 'dart:async';
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/confirm_delete.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_sheet_details.dart';
import 'package:todo/ui/note_task_details.dart';
@ -61,53 +62,54 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
firstLine.isEmpty ? '(empty)' : firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// User content; untitled shows nothing, not a placeholder.
if (firstLine.isNotEmpty)
Text(
firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
NoteSheetDetails(note: widget.note),
const SizedBox(height: 16),
_EnumChips<Status>(
label: 'Status',
GlyphChips<Status>(
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
glyphOf: statusGlyph,
onTap: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
label: 'Priority',
GlyphChips<Priority>(
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
values: Priority.values,
selected: {_priority},
labelOf: (p) => p.label,
onSelected: (p) {
glyphOf: (p) => priorityGlyph(context, p),
onTap: (p) {
setState(() => _priority = p);
unawaited(_persist());
},
),
NoteTaskDetails(note: widget.note),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.delete_outline),
label: const Text('Delete note'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
Align(
child: IconButton.outlined(
color: Theme.of(context).colorScheme.error,
icon: const Icon(
Icons.delete_outline,
semanticLabel: 'Delete note',
),
onPressed: () async {
if (!await confirmDeleteNote(context)) return;
await widget.onDelete();
if (context.mounted) Navigator.of(context).pop();
},
),
onPressed: () async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.onDelete();
if (context.mounted) Navigator.of(context).pop();
},
),
],
),
@ -116,42 +118,3 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
);
}
}
/// Single-select chip row for an enum (used for per-note priority/status).
class _EnumChips<T> extends StatelessWidget {
const _EnumChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onSelected,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> onSelected;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final v in values)
ChoiceChip(
label: Text(labelOf(v)),
selected: selected.contains(v),
onSelected: (_) => onSelected(v),
),
],
),
],
);
}
}

View File

@ -9,19 +9,29 @@ import 'dart:async';
import 'package:flutter/material.dart';
/// Glyph for "created" (a note added), shared by the filter and sort menus.
const IconData kCreatedIcon = Icons.post_add;
/// Glyph for "last updated", shared by the filter and sort menus.
const IconData kUpdatedIcon = Icons.update;
/// A labelled date range shown as one preset menu plus a custom picker.
class DateRangeField extends StatelessWidget {
/// Creates a labelled date-range control.
const DateRangeField({
required this.label,
required this.icon,
required this.semanticLabel,
required this.from,
required this.to,
required this.onChanged,
super.key,
});
/// Heading shown above the control (e.g. 'Created').
final String label;
/// Glyph shown in the control's border in place of a written heading.
final IconData icon;
/// Spoken name of the control (e.g. 'Created'); never painted.
final String semanticLabel;
/// Start of the selected range, or null when unbounded.
final DateTime? from;
@ -86,7 +96,8 @@ class DateRangeField extends StatelessWidget {
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
// custom range is shown must still reopen the picker.
return PopupMenuButton<_Preset>(
tooltip: label,
// Empty, not the label: a tooltip is painted on long-press.
tooltip: '',
onSelected: (p) => _select(context, p),
itemBuilder: (_) => [
for (final p in _Preset.values)
@ -94,7 +105,7 @@ class DateRangeField extends StatelessWidget {
],
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, semanticLabel: semanticLabel),
isDense: true,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:todo/data/app_settings.dart';
@ -8,6 +7,7 @@ import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/confirm_delete.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/note_form.dart';
@ -85,12 +85,7 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
Future<void> _delete() async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
if (!await confirmDeleteNote(context)) return;
await widget.repository.delete(_note.id);
if (mounted) Navigator.of(context).pop();
}
@ -100,7 +95,8 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
final title = noteTitle(_note.text);
return Scaffold(
appBar: AppBar(
title: Text(title.isEmpty ? '(empty)' : title),
// An untitled note shows no title rather than a written placeholder.
title: title.isEmpty ? null : Text(title),
actions: [
AttachButton(
onFiles: _attachFiles,
@ -114,8 +110,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
onPressed: _copy,
),
IconButton(
tooltip: 'Delete note',
icon: const Icon(Icons.delete_outline),
icon: const Icon(
Icons.delete_outline,
semanticLabel: 'Delete note',
),
onPressed: _delete,
),
],

View File

@ -43,7 +43,7 @@ class NoteEditorChrome extends StatelessWidget {
DropdownButtonFormField<String>(
initialValue: template.id,
decoration: const InputDecoration(
labelText: 'Template',
label: Icon(Icons.description, semanticLabel: 'Template'),
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@ -54,9 +54,7 @@ class NoteEditorChrome extends StatelessWidget {
],
onChanged: (id) {
if (id == null) return;
onTemplateSelected(
NoteTemplate.all.firstWhere((t) => t.id == id),
);
onTemplateSelected(NoteTemplate.all.firstWhere((t) => t.id == id));
},
),
const SizedBox(height: 8),
@ -67,8 +65,7 @@ class NoteEditorChrome extends StatelessWidget {
segments: [
const ButtonSegment(
value: NoteEditorMode.preview,
icon: Icon(Icons.visibility_outlined),
label: Text('View'),
icon: Icon(Icons.visibility_outlined, semanticLabel: 'View'),
),
// Guided is offered for any structured template; tapping it
// on an empty draft opens the wizard, and is blocked at
@ -76,13 +73,11 @@ class NoteEditorChrome extends StatelessWidget {
if (!template.isFreeform)
const ButtonSegment(
value: NoteEditorMode.guided,
icon: Icon(Icons.checklist),
label: Text('Guided'),
icon: Icon(Icons.checklist, semanticLabel: 'Guided'),
),
const ButtonSegment(
value: NoteEditorMode.raw,
icon: Icon(Icons.notes),
label: Text('Raw'),
icon: Icon(Icons.notes, semanticLabel: 'Raw'),
),
],
selected: {mode},

View File

@ -44,10 +44,8 @@ class NoteEditorRawField extends StatelessWidget {
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
style: Theme.of(context).textTheme.bodyLarge,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Write your idea…',
),
// No hint: the cursor in an empty full-height box says "type".
decoration: const InputDecoration(border: InputBorder.none),
onChanged: (_) => onChanged(),
),
),

View File

@ -66,8 +66,7 @@ class NoteEditorStepPage extends StatelessWidget {
Align(
alignment: Alignment.centerLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Exit guided',
icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
onPressed: onExit,
),
),
@ -96,10 +95,12 @@ class NoteEditorStepPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.isTitle ? 'title' : section.label,
style: theme.textTheme.titleMedium,
),
// 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,
@ -140,16 +141,22 @@ class NoteEditorStepPage extends StatelessWidget {
if (index > 0)
TextButton(
onPressed: () => onGoToStep(index - 1),
child: const Text('Back'),
child: const Icon(
Icons.navigate_before,
semanticLabel: 'Back',
),
),
const Spacer(),
if (index < total - 1)
FilledButton(
onPressed: () => onGoToStep(index + 1),
child: const Text('Next'),
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
)
else
FilledButton(onPressed: onExit, child: const Text('Done')),
FilledButton(
onPressed: onExit,
child: const Icon(Icons.check, semanticLabel: 'Done'),
),
],
),
),

View File

@ -13,6 +13,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';
/// Asks for a priority, then a template, then hands both back via [onStart].
class NoteEditorWizard extends StatefulWidget {
@ -48,7 +49,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final semanticLabel = 'Step ${_step + 1} of 2';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@ -56,11 +57,24 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
Row(
children: [
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Cancel',
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
onPressed: widget.onCancel,
),
Text('Step ${_step + 1} of 2', style: theme.textTheme.labelLarge),
// Two step dots, the current one filled: position by shape.
Semantics(
label: semanticLabel,
child: ExcludeSemantics(
child: Row(
children: [
for (var i = 0; i < 2; i++)
Icon(
i == _step ? Icons.circle : Icons.circle_outlined,
size: 12,
),
],
),
),
),
],
),
const SizedBox(height: 8),
@ -74,14 +88,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<Priority>(
initialValue: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
for (final p in Priority.values)
DropdownMenuItem(value: p, child: Text(p.label)),
DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
],
onChanged: (p) {
if (p != null) setState(() => _priority = p);
@ -92,7 +106,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => setState(() => _step = 1),
child: const Text('Next'),
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
),
),
];
@ -104,7 +118,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<String>(
initialValue: _template.id,
decoration: const InputDecoration(
labelText: 'Template',
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@ -124,12 +138,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
children: [
TextButton(
onPressed: () => setState(() => _step = 0),
child: const Text('Back'),
child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => widget.onStart(_priority, _template),
child: const Text('Start'),
child: const Icon(Icons.play_arrow, semanticLabel: 'Start'),
),
],
),

View File

@ -8,24 +8,30 @@ library;
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/enum_glyphs.dart';
/// A compact labelled dropdown for picking an enum value (priority/status).
/// A compact dropdown for picking an enum value (priority/status), headed by
/// a glyph and showing each value as its glyph.
///
/// Generic over the enum type [T] so the same control drives both pickers
/// without duplication; [labelOf] maps a value to its display string.
/// without duplication; [glyphOf] maps a value to its glyph.
class MetaDropdown<T> extends StatelessWidget {
/// Creates a dropdown showing [value] out of [values].
const MetaDropdown({
required this.label,
required this.heading,
required this.semanticLabel,
required this.value,
required this.values,
required this.labelOf,
required this.glyphOf,
required this.onChanged,
super.key,
});
/// Label shown above the control.
final String label;
/// Glyph in the control's border in place of a written heading.
final IconData heading;
/// Spoken name of the control; never painted.
final String semanticLabel;
/// Currently selected value.
final T value;
@ -33,8 +39,8 @@ class MetaDropdown<T> extends StatelessWidget {
/// Every selectable value.
final List<T> values;
/// Maps a value to its display string.
final String Function(T) labelOf;
/// Maps a value to its glyph.
final Widget Function(T) glyphOf;
/// Called with the newly picked value.
final ValueChanged<T> onChanged;
@ -43,7 +49,7 @@ class MetaDropdown<T> extends StatelessWidget {
Widget build(BuildContext context) {
return InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(heading, semanticLabel: semanticLabel),
isDense: true,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@ -55,7 +61,7 @@ class MetaDropdown<T> extends StatelessWidget {
isExpanded: true,
items: [
for (final v in values)
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))),
DropdownMenuItem<T>(value: v, child: glyphOf(v)),
],
onChanged: (v) {
if (v != null) onChanged(v);
@ -98,20 +104,22 @@ class NoteMetaRow extends StatelessWidget {
children: [
Expanded(
child: MetaDropdown<Priority>(
label: 'Priority',
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
value: priority,
values: Priority.values,
labelOf: (p) => p.label,
glyphOf: (p) => priorityGlyph(context, p),
onChanged: onPriorityChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: MetaDropdown<Status>(
label: 'Status',
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
value: status,
values: Status.values,
labelOf: (s) => s.label,
glyphOf: statusGlyph,
onChanged: onStatusChanged,
),
),

View File

@ -56,8 +56,7 @@ class NoteTile extends StatelessWidget {
),
subtitle: Text(meta),
trailing: IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Quick actions',
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
onPressed: onActions,
),
onTap: onTap,

View File

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_date_range_field.dart';
import 'package:todo/ui/notes_list_defaults.dart';
@ -66,7 +67,6 @@ class _FilterSheetState extends State<FilterSheet> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
@ -82,33 +82,38 @@ class _FilterSheetState extends State<FilterSheet> {
children: [
Row(
children: [
Text('Filters', style: theme.textTheme.titleLarge),
const Icon(Icons.filter_list, semanticLabel: 'Filters'),
const Spacer(),
TextButton(
onPressed: _clearAll,
child: const Text('Clear all'),
child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
),
],
),
const SizedBox(height: 8),
_MultiChips<Status>(
label: 'Status',
GlyphChips<Status>(
multi: true,
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
values: Status.values,
selected: _statuses,
labelOf: (s) => s.label,
onToggle: (s) => _toggle(_statuses, s),
glyphOf: statusGlyph,
onTap: (s) => _toggle(_statuses, s),
),
const SizedBox(height: 12),
_MultiChips<Priority>(
label: 'Priority',
GlyphChips<Priority>(
multi: true,
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
values: Priority.values,
selected: _priorities,
labelOf: (p) => p.label,
onToggle: (p) => _toggle(_priorities, p),
glyphOf: (p) => priorityGlyph(context, p),
onTap: (p) => _toggle(_priorities, p),
),
const SizedBox(height: 12),
DateRangeField(
label: 'Created',
icon: kCreatedIcon,
semanticLabel: 'Created',
from: _createdFrom,
to: _createdTo,
onChanged: (from, to) => setState(() {
@ -118,7 +123,8 @@ class _FilterSheetState extends State<FilterSheet> {
),
const SizedBox(height: 12),
DateRangeField(
label: 'Last updated',
icon: kUpdatedIcon,
semanticLabel: 'Last updated',
from: _updatedFrom,
to: _updatedTo,
onChanged: (from, to) => setState(() {
@ -129,7 +135,7 @@ class _FilterSheetState extends State<FilterSheet> {
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(context).pop(_build()),
child: const Text('Apply'),
child: const Icon(Icons.check, semanticLabel: 'Apply'),
),
],
),
@ -139,45 +145,6 @@ class _FilterSheetState extends State<FilterSheet> {
}
}
/// Multi-select chip group for an enum (used by the filter sheet).
class _MultiChips<T> extends StatelessWidget {
const _MultiChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onToggle,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> onToggle;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final v in values)
FilterChip(
label: Text(labelOf(v)),
selected: selected.contains(v),
onSelected: (_) => onToggle(v),
),
],
),
],
);
}
}
/// A date-range control offering quick presets plus a custom range picker.
///
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to

View File

@ -134,7 +134,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
final badgeCount = userFacetCount(_filter);
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
title: const Icon(Icons.note, semanticLabel: 'Notes'),
actions: [
_selectButton(),
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
@ -148,26 +148,27 @@ class _NotesListScreenState extends State<NotesListScreen> {
onPressed: _toggleClaudeNotes,
),
PopupMenuButton<NoteSort>(
tooltip: 'Sort',
icon: const Icon(Icons.sort),
// Empty, not omitted: null falls back to the painted "Show menu".
tooltip: '',
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
initialValue: _sort,
onSelected: _setSort,
itemBuilder: (_) => const [
PopupMenuItem(
value: NoteSort.modifiedDesc,
child: Text('Last updated'),
child: Icon(Icons.update, semanticLabel: 'Last updated'),
),
PopupMenuItem(
value: NoteSort.createdDesc,
child: Text('Newest created'),
child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
),
PopupMenuItem(
value: NoteSort.alphabetical,
child: Text('Alphabetical'),
child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
),
PopupMenuItem(
value: NoteSort.priorityDesc,
child: Text('Priority'),
child: Icon(Icons.flag_outlined, semanticLabel: 'Priority'),
),
],
),
@ -181,8 +182,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
label: Text('$badgeCount'),
offset: const Offset(-8, 4),
child: IconButton(
tooltip: 'Filter',
icon: const Icon(Icons.filter_list),
icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
onPressed: _openFilters,
),
),
@ -198,15 +198,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
onChanged: _onSearchChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search notes…',
prefixIcon: const Icon(Icons.search),
isDense: true,
border: const OutlineInputBorder(),
suffixIcon: _searchController.text.isEmpty
? null
: IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
icon: const Icon(
Icons.clear,
semanticLabel: 'Clear search',
),
onPressed: () {
_searchController.clear();
_onSearchChanged('');

View File

@ -105,11 +105,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// session is available, best-effort mirrors it so the choice follows the
/// user to their other devices.
///
/// The local write always lands — a signed-in-as-wrong-account device
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the
/// toggle down with it, so opening the client has its own try/catch,
/// separate from (and preceding) the try that guards the local write and
/// mirror push.
/// The local write always lands: a [FirebaseAuthError] from opening the
/// client (wrong account) is caught on its own, before the write.
Future<void> _setAdvancedMode(bool value) async {
final analytics = widget.analytics;
if (analytics != null) {
@ -206,7 +203,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: AppBar(
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -214,33 +213,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
valueListenable: widget.appSettings,
builder: (context, settings, _) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable advanced'),
subtitle: const Text(
'Priority/status, templates, view modes, and sync details '
'in the capture screen',
),
secondary: const Icon(Icons.tune, semanticLabel: _advanced),
value: settings.advancedMode,
onChanged: (value) => unawaited(_setAdvancedMode(value)),
),
),
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Sync settings'),
subtitle: const Text('Firebase sync and notes backup'),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openSyncSettings()),
_link(
const Icon(Icons.sync, semanticLabel: 'Sync settings: Firebase'),
_openSyncSettings,
),
CloudImagesTile(httpClient: widget.httpClient),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),
subtitle: const Text('Cutover mirror — not recommended'),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openGitHubMirror()),
_link(
Icon(
Icons.cloud_sync,
color: Theme.of(context).colorScheme.outline,
semanticLabel: 'Advanced sync (GitHub): not recommended',
),
_openGitHubMirror,
),
],
),
);
}
// Spoken only: what the advanced switch turns on.
static const _advanced = 'Enable advanced: priority, status, view modes';
/// A row that opens a sub-screen: its glyph (muted = not recommended).
Widget _link(Icon icon, Future<void> Function() open) => ListTile(
contentPadding: EdgeInsets.zero,
leading: icon,
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(open()),
);
}

View File

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

View File

@ -29,18 +29,18 @@ void main() {
// Defaults to Raw: a single text field, no note persisted yet.
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Guided'), findsOneWidget);
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
expect(find.text('0'), findsOneWidget);
// Tapping Guided on the empty draft opens the priority+template wizard
// rather than jumping straight into the stepper.
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
expect(find.text('Step 1 of 2'), findsOneWidget);
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
await tester.tap(find.text('Start'));
await tester.tap(find.bySemanticsLabel('Start'));
await tester.pump();
// Now on step 1 of the full-screen step page.
@ -55,7 +55,7 @@ void main() {
testWidgets('saving the untouched template creates no note', (tester) async {
final repo = await pumpCapture(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump();
expect(await repo.listNotes(), isEmpty);
@ -83,7 +83,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, 'A real idea');
await tester.pump();
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump();
expect(await repo.listNotes(), hasLength(1));
@ -98,7 +98,7 @@ void main() {
) async {
await pumpCapture(tester); // empty prefs → no token → not configured
await tester.tap(find.byTooltip('Sync'));
await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); // settings load + snackbar
await tester.pump();
@ -114,11 +114,11 @@ void main() {
) async {
await pumpCapture(tester);
await tester.tap(find.byTooltip('Notes'));
await tester.tap(find.bySemanticsLabel('Notes'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // route transition
expect(find.text('Notes'), findsOneWidget); // list screen app bar title
expect(find.byIcon(Icons.note), findsOneWidget); // list screen title
});
testWidgets('changing the priority dropdown updates the saved note', (
@ -134,7 +134,7 @@ void main() {
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); // menu open
await tester.tap(find.text('High').last);
await tester.tap(find.bySemanticsLabel('High').last);
await tester.pump();
expect((await repo.listNotes()).single.priority, Priority.high);
@ -153,7 +153,7 @@ void main() {
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); // menu open
await tester.tap(find.text('In progress').last);
await tester.tap(find.bySemanticsLabel('In progress').last);
await tester.pump();
expect((await repo.listNotes()).single.status, Status.inProgress);

View File

@ -35,7 +35,7 @@ void main() {
});
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync'));
await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); // setState(_syncing = true)
await tester.pump(); // service runs, snackbar scheduled
await tester.pump(); // snackbar builds
@ -55,7 +55,7 @@ void main() {
final mock = MockClient((_) async => throw Exception('offline'));
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync'));
await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump();
await tester.pump();
await tester.pump();
@ -85,7 +85,7 @@ void main() {
});
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync'));
await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump();
await tester.pump();
await tester.pump();
@ -101,7 +101,7 @@ void main() {
) async {
await pumpCapture(tester);
await tester.tap(find.byTooltip('Settings'));
await tester.tap(find.bySemanticsLabel('Settings'));
await tester.pumpAndSettle(); // route transition
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
@ -112,11 +112,11 @@ void main() {
// without popping a result at all, so a stale in-memory value would be
// wrong there too). This exercises that reload-after-return path, then
// Back returns to capture.
await tester.tap(find.text('Advanced sync (GitHub)'));
await tester.tap(find.byIcon(Icons.cloud_sync));
await tester.pumpAndSettle(); // route transition
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
await tester.tap(find.text('Save'));
await tester.tap(find.bySemanticsLabel('Save'));
await tester.pumpAndSettle(); // save + pop transition back to Settings
expect(find.byType(SettingsScreen), findsOneWidget);
@ -124,7 +124,7 @@ void main() {
await tester.pumpAndSettle(); // pop transition back to capture
expect(find.byType(SettingsScreen), findsNothing); // back on capture
expect(find.byTooltip('Settings'), findsOneWidget);
expect(find.bySemanticsLabel('Settings'), findsOneWidget);
});
// A MockClient that records request methods and answers the sync flow:
@ -148,11 +148,11 @@ void main() {
await tester.pump();
// The editor's own template/mode chrome is gone.
expect(find.text('Guided'), findsNothing);
expect(find.text('Template'), findsNothing);
expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.bySemanticsLabel('Template'), findsNothing);
// The capture screen's priority/status row is gone.
expect(find.text('Priority'), findsNothing);
expect(find.text('Status'), findsNothing);
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(find.text('1'), findsOneWidget);
@ -161,29 +161,23 @@ void main() {
},
);
testWidgets(
'draft text survives an advancedMode toggle mid-typing',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: true),
);
await pumpCapture(tester, appSettings: appSettings);
testWidgets('draft text survives an advancedMode toggle mid-typing', (
tester,
) async {
final appSettings = ValueNotifier(const AppSettings(advancedMode: true));
await pumpCapture(tester, appSettings: appSettings);
await tester.enterText(
find.byType(TextField).first,
'my important idea',
);
await tester.pump();
await tester.enterText(find.byType(TextField).first, 'my important idea');
await tester.pump();
// NoteForm's metadata row is conditionally present, so toggling
// advancedMode changes the editor's position beneath it — a key one
// level too shallow doesn't survive that reposition and Flutter
// remounts the editor with an empty draft, losing the typing.
appSettings.value = const AppSettings(advancedMode: false);
await tester.pump();
// NoteForm's metadata row is conditionally present, so toggling
// advancedMode changes the editor's position beneath it — a key one
// level too shallow doesn't survive that reposition and Flutter
// remounts the editor with an empty draft, losing the typing.
appSettings.value = const AppSettings(advancedMode: false);
await tester.pump();
final field = tester.widget<TextField>(find.byType(TextField).first);
expect(field.controller?.text, contains('my important idea'));
},
);
final field = tester.widget<TextField>(find.byType(TextField).first);
expect(field.controller?.text, contains('my important idea'));
});
}

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/confirm_delete.dart';
/// Opens the dialog and returns a getter for its eventual answer.
Future<bool? Function()> _open(WidgetTester tester) async {
bool? answer;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async => answer = await confirmDeleteNote(context),
child: const SizedBox.square(dimension: 40),
),
),
),
),
);
await tester.tap(find.byType(TextButton));
await tester.pumpAndSettle();
return () => answer;
}
void main() {
testWidgets('the delete glyph answers yes', (tester) async {
final answer = await _open(tester);
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
await tester.pumpAndSettle();
expect(answer(), isTrue);
});
testWidgets('cancel is autofocused, so Return keeps the note', (
tester,
) async {
final answer = await _open(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(answer(), isFalse);
});
testWidgets('Escape answers no', (tester) async {
final answer = await _open(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(answer(), isFalse);
});
testWidgets('tapping the barrier answers no', (tester) async {
final answer = await _open(tester);
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
expect(answer(), isFalse);
});
}

View File

@ -72,7 +72,7 @@ void main() {
httpClient: mock,
);
await tester.tap(find.text('Connect GitHub'));
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
@ -135,7 +135,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 400)); // route transition
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
await tester.tap(find.text('Save'));
await tester.tap(find.bySemanticsLabel('Save'));
await tester.pump(); // run _save (persist + pop)
await tester.pump(const Duration(milliseconds: 400)); // pop transition
@ -195,7 +195,7 @@ void main() {
httpClient: mock,
);
await tester.tap(find.text('Connect GitHub'));
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); // requestDeviceCode
await tester.pump(); // dialog builds, poll starts (interval 0)
expect(find.text('WXYZ-1234'), findsOneWidget);

View File

@ -43,7 +43,7 @@ void main() {
tester,
) async {
await pumpMirror(tester);
await tester.tap(find.text('Connect GitHub'));
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump();
expect(
find.textContaining('Enter the OAuth App client id'),
@ -55,7 +55,7 @@ void main() {
final mock = MockClient((_) async => http.Response('{}', 200));
await pumpMirror(tester, httpClient: mock);
await tester.tap(find.text('Test connection'));
await tester.tap(find.bySemanticsLabel('Test connection'));
await tester.pump(); // start
await tester.pump(); // resolve future + rebuild
@ -66,7 +66,7 @@ void main() {
final mock = MockClient((_) async => http.Response('', 404));
await pumpMirror(tester, httpClient: mock);
await tester.tap(find.text('Test connection'));
await tester.tap(find.bySemanticsLabel('Test connection'));
await tester.pump();
await tester.pump();
@ -77,7 +77,7 @@ void main() {
final mock = MockClient((_) async => throw Exception('offline'));
await pumpMirror(tester, httpClient: mock);
await tester.tap(find.text('Test connection'));
await tester.tap(find.bySemanticsLabel('Test connection'));
await tester.pump();
await tester.pump();
@ -97,7 +97,7 @@ void main() {
httpClient: mock,
);
await tester.tap(find.text('Connect GitHub'));
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump();
await tester.pump();
@ -133,7 +133,7 @@ void main() {
httpClient: mock,
);
await tester.tap(find.text('Connect GitHub'));
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); // requestDeviceCode
await tester.pump(); // dialog builds, shows the user code
expect(find.text('WXYZ-1234'), findsOneWidget);

View File

@ -38,7 +38,7 @@ void main() {
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); // one frame: the cue must not wait for anything
expect(haptics, [_lightImpact]);
@ -52,7 +52,7 @@ void main() {
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(_mark, findsOneWidget);
@ -74,7 +74,7 @@ void main() {
final haptics = recordHaptics(tester);
await pumpCapture(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, isEmpty);
@ -87,16 +87,18 @@ void main() {
final haptics = recordHaptics(tester);
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); // the ticker's clock starts on its first frame
await tester.pump(const Duration(milliseconds: 700));
await tester.tap(find.byTooltip('New note')); // nothing new to capture
await tester.tap(
find.bySemanticsLabel('New note'),
); // nothing new to capture
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, hasLength(1));
expect(_mark, findsNothing);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, hasLength(2));
expect(_mark, findsOneWidget);
@ -111,7 +113,7 @@ void main() {
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump();
expect(haptics, [_lightImpact]);
@ -130,7 +132,7 @@ void main() {
await pumpCapture(tester);
final before = tester.getTopLeft(find.text('0'));
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getTopLeft(find.text('1')), before);
await tester.pump(const Duration(milliseconds: 400));

View File

@ -73,7 +73,7 @@ void main() {
final total = spec.sections.length;
expect(_barValue(tester), closeTo(1 / total, 1e-9));
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
await tester.pump(AppDuration.base ~/ 2);
expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total));
@ -85,7 +85,7 @@ void main() {
testWidgets('remove-animations: the progress bar jumps', (tester) async {
disableAnimations(tester);
await pumpEditor(tester, initialTemplate: spec);
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9));
});

View File

@ -61,7 +61,7 @@ void main() {
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
);
await tester.pumpAndSettle();
await tester.tap(find.text('High').last);
await tester.tap(find.bySemanticsLabel('High').last);
await tester.pumpAndSettle();
expect((await repo.listNotes()).single.priority, Priority.high);
@ -74,7 +74,7 @@ void main() {
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Done').last);
await tester.tap(find.bySemanticsLabel('Done').last);
await tester.pumpAndSettle();
expect((await repo.listNotes()).single.status, Status.done);
@ -85,7 +85,7 @@ void main() {
) async {
final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold'));
await tester.tap(find.text('Raw'));
await tester.tap(find.bySemanticsLabel('Raw'));
await tester.pump();
await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body');
await tester.pump();
@ -101,20 +101,20 @@ void main() {
await tester.enterText(find.byType(TextField), '');
await tester.pump();
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
expect(find.text('Step 1 of 2'), findsOneWidget);
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
// Wizard hides the Status dropdown (onChromeVisibleChanged(false)).
expect(find.text('Status'), findsNothing);
expect(find.bySemanticsLabel('Status'), findsNothing);
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
await tester.pumpAndSettle();
await tester.tap(find.text('High').last);
await tester.tap(find.bySemanticsLabel('High').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
await tester.tap(find.text('Start'));
await tester.tap(find.bySemanticsLabel('Start'));
await tester.pump();
expect((await repo.listNotes()).single.priority, Priority.high);
@ -134,11 +134,11 @@ void main() {
advancedMode: false,
);
expect(find.text('Template'), findsNothing);
expect(find.text('Guided'), findsNothing);
expect(find.text('Raw'), findsNothing);
expect(find.text('Priority'), findsNothing);
expect(find.text('Status'), findsNothing);
expect(find.bySemanticsLabel('Template'), findsNothing);
expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.bySemanticsLabel('Raw'), findsNothing);
expect(find.bySemanticsLabel('Priority'), findsNothing);
expect(find.bySemanticsLabel('Status'), findsNothing);
// The body itself is still editable — only the chrome is gone.
expect(find.byType(TextField), findsOneWidget);
});
@ -146,13 +146,13 @@ void main() {
testWidgets('the delete action removes the note and pops', (tester) async {
final repo = await pumpDetail(tester, seedNote('# Bye'));
await tester.tap(find.byTooltip('Delete note'));
await tester.tap(find.bySemanticsLabel('Delete note'));
await tester.pumpAndSettle();
// Delete is now confirmed. Deleting a note is irreversible and there is no
// undo, so a single Return on a focused control must not destroy it.
expect(find.text('Delete note?'), findsOneWidget);
await tester.tap(find.text('Delete'));
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
await tester.pumpAndSettle();
expect(await repo.listNotes(), isEmpty);
@ -162,9 +162,9 @@ void main() {
testWidgets('cancelling the delete keeps the note', (tester) async {
final repo = await pumpDetail(tester, seedNote('# Keep me'));
await tester.tap(find.byTooltip('Delete note'));
await tester.tap(find.bySemanticsLabel('Delete note'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.tap(find.byKey(const ValueKey('confirm-cancel')));
await tester.pumpAndSettle();
expect(await repo.listNotes(), hasLength(1));

View File

@ -13,7 +13,7 @@ void main() {
) async {
final emitted = await pumpEditor(tester, initialTemplate: spec);
expect(find.byTooltip('Exit guided'), findsOneWidget);
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'Dark mode');
await tester.pump();
@ -28,11 +28,11 @@ void main() {
expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
await tester.tap(find.text('Back'));
await tester.tap(find.bySemanticsLabel('Back'));
await tester.pump();
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
@ -50,20 +50,22 @@ void main() {
final section = spec.sections[i];
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
expect(
find.text(section.isTitle ? 'title' : section.label),
section.isTitle
? find.bySemanticsLabel('title')
: find.text(section.label),
findsWidgets,
reason: 'step ${i + 1} should show "${section.label}"',
);
expect(find.text(section.helper), findsOneWidget);
if (i < spec.sections.length - 1) {
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
}
}
// The last step is `read first`; there is no Next past it.
expect(spec.sections.last.label, 'read first');
expect(find.text('Next'), findsNothing);
expect(find.bySemanticsLabel('Next'), findsNothing);
});
testWidgets('pasting a bare link drops the draft to the blank template', (
@ -84,7 +86,7 @@ void main() {
// Freeform now, so there is no seven-step stepper to walk for a link.
expect(find.text('Blank'), findsOneWidget);
expect(find.text('Guided'), findsNothing);
expect(find.bySemanticsLabel('Guided'), findsNothing);
// Adding prose puts the spec template back — lossless in both directions.
await tester.enterText(
@ -93,7 +95,7 @@ void main() {
);
await tester.pump();
expect(find.text('LLM design spec'), findsOneWidget);
expect(find.text('Guided'), findsOneWidget);
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
});
testWidgets('an explicit template choice survives further typing', (
@ -126,7 +128,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, '# Render me');
await tester.pump();
await tester.tap(find.text('View'));
await tester.tap(find.bySemanticsLabel('View'));
await tester.pump();
expect(find.byType(MarkdownView), findsOneWidget);

View File

@ -15,7 +15,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, 'T');
await tester.pump();
await tester.tap(find.byTooltip('Exit guided'));
await tester.tap(find.bySemanticsLabel('Exit guided'));
await tester.pump();
final raw = tester.widget<TextField>(find.byType(TextField));
@ -38,7 +38,7 @@ void main() {
);
// Structured template + non-conforming raw text → switching is refused.
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
expect(find.textContaining("doesn't match the template"), findsOneWidget);
@ -56,25 +56,28 @@ void main() {
// Bare guided -> raw via the back arrow makes the (still conforming)
// body the source…
await tester.tap(find.byTooltip('Exit guided'));
await tester.tap(find.bySemanticsLabel('Exit guided'));
await tester.pump();
expect(find.byType(LinearProgressIndicator), findsNothing);
// …then raw -> guided re-parses the conforming body straight back into
// the bare stepper — no wizard, since the content isn't empty.
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped
expect(
find.bySemanticsLabel('Step 1 of 2'),
findsNothing,
); // wizard was skipped
},
);
testWidgets('freeform template offers only View and Raw', (tester) async {
final emitted = await pumpEditor(tester, initialTemplate: blank);
expect(find.text('Guided'), findsNothing);
expect(find.text('View'), findsOneWidget);
expect(find.text('Raw'), findsOneWidget);
expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.bySemanticsLabel('View'), findsOneWidget);
expect(find.bySemanticsLabel('Raw'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'free text');
await tester.pump();
@ -89,7 +92,10 @@ void main() {
initialTemplate: spec,
initialMode: NoteEditorMode.raw,
);
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered
expect(
find.bySemanticsLabel('Guided'),
findsOneWidget,
); // structured: Guided offered
await tester.tap(find.text('LLM design spec').first); // open the menu
await tester.pumpAndSettle();
@ -97,7 +103,7 @@ void main() {
await tester.pumpAndSettle();
// Freeform now: Guided is no longer offered.
expect(find.text('Guided'), findsNothing);
expect(find.bySemanticsLabel('Guided'), findsNothing);
});
testWidgets('detects a conforming note (no template given) as guided', (
@ -110,7 +116,7 @@ void main() {
find.byType(LinearProgressIndicator),
findsOneWidget,
); // guided by default
expect(find.byTooltip('Exit guided'), findsOneWidget);
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
});
testWidgets('detects a legacy note (no template given) as freeform raw', (
@ -120,7 +126,7 @@ void main() {
// Non-conforming → blank/raw, no guided stepper offered.
expect(find.byType(LinearProgressIndicator), findsNothing);
expect(find.text('Guided'), findsNothing);
expect(find.bySemanticsLabel('Guided'), findsNothing);
});
testWidgets('initialMode preview opens directly in the rendered view', (
@ -147,7 +153,7 @@ void main() {
initialMode: NoteEditorMode.preview,
);
await tester.tap(find.text('Raw'));
await tester.tap(find.bySemanticsLabel('Raw'));
await tester.pump();
final raw = tester.widget<TextField>(find.byType(TextField));

View File

@ -17,10 +17,10 @@ void main() {
initialMode: NoteEditorMode.raw,
);
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
expect(find.text('Step 1 of 2'), findsOneWidget);
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
expect(find.byType(DropdownButtonFormField<Priority>), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsNothing);
},
@ -34,17 +34,17 @@ void main() {
initialTemplate: spec,
initialMode: NoteEditorMode.raw,
);
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
expect(find.text('Step 2 of 2'), findsOneWidget);
expect(find.bySemanticsLabel('Step 2 of 2'), findsOneWidget);
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
await tester.tap(find.text('Back'));
await tester.tap(find.bySemanticsLabel('Back'));
await tester.pump();
expect(find.text('Step 1 of 2'), findsOneWidget);
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
},
);
@ -56,9 +56,9 @@ void main() {
initialTemplate: spec,
initialMode: NoteEditorMode.raw,
);
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
await tester.tap(find.text('LLM design spec').first); // open the menu
@ -68,7 +68,7 @@ void main() {
// Select the (only) offered template, exercising the dropdown's onChanged.
await tester.tap(find.text('LLM design spec').last);
await tester.pumpAndSettle();
expect(find.text('Step 2 of 2'), findsOneWidget);
expect(find.bySemanticsLabel('Step 2 of 2'), findsOneWidget);
});
testWidgets(
@ -81,22 +81,22 @@ void main() {
initialMode: NoteEditorMode.raw,
onPriorityChanged: (p) => committed = p,
);
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
await tester.pumpAndSettle();
await tester.tap(find.text('High').last);
await tester.tap(find.bySemanticsLabel('High').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
await tester.tap(find.text('Start'));
await tester.tap(find.bySemanticsLabel('Start'));
await tester.pump();
expect(committed, Priority.high);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.byTooltip('Exit guided'), findsOneWidget);
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
},
);
@ -110,16 +110,19 @@ void main() {
initialMode: NoteEditorMode.raw,
onChromeVisibleChanged: chromeVisible.add,
);
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump();
expect(chromeVisible.last, false);
await tester.tap(find.byTooltip('Cancel'));
await tester.tap(find.bySemanticsLabel('Cancel'));
await tester.pump();
expect(chromeVisible.last, true);
expect(find.byType(LinearProgressIndicator), findsNothing);
expect(find.text('Raw'), findsOneWidget); // chrome's mode selector is back
expect(
find.bySemanticsLabel('Raw'),
findsOneWidget,
); // chrome's mode selector is back
});
testWidgets('exiting bare Guided via the back arrow restores the chrome', (
@ -135,11 +138,11 @@ void main() {
);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
await tester.tap(find.byTooltip('Exit guided'));
await tester.tap(find.bySemanticsLabel('Exit guided'));
await tester.pump();
expect(chromeVisible, contains(true));
expect(find.text('Raw'), findsOneWidget);
expect(find.bySemanticsLabel('Raw'), findsOneWidget);
});
testWidgets('last step shows Done instead of Next', (tester) async {
@ -147,12 +150,12 @@ void main() {
// Navigate to the final step.
for (var i = 0; i < spec.sections.length - 1; i++) {
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
}
expect(find.text('Done'), findsOneWidget);
expect(find.text('Next'), findsNothing);
expect(find.bySemanticsLabel('Done'), findsOneWidget);
expect(find.bySemanticsLabel('Next'), findsNothing);
expect(
find.text('${spec.sections.length} / ${spec.sections.length}'),
findsOneWidget,
@ -167,10 +170,10 @@ void main() {
await tester.pump();
for (var i = 0; i < spec.sections.length - 1; i++) {
await tester.tap(find.text('Next'));
await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump();
}
await tester.tap(find.text('Done'));
await tester.tap(find.bySemanticsLabel('Done'));
await tester.pump();
expect(find.byType(LinearProgressIndicator), findsNothing);

View File

@ -103,8 +103,8 @@ void main() {
await t.tap(find.text('Open me'));
await t.pumpAndSettle();
expect(find.text('Priority'), findsOneWidget);
expect(find.text('View'), findsOneWidget);
expect(find.bySemanticsLabel('Priority'), findsOneWidget);
expect(find.bySemanticsLabel('View'), findsOneWidget);
});
testWidgets('long-press opens the sheet with body, dates and actions', (
@ -121,10 +121,10 @@ void main() {
expect(find.textContaining('Two litres of oat'), findsOneWidget);
expect(find.textContaining('Edited'), findsOneWidget);
expect(find.textContaining('Created'), findsOneWidget);
await t.tap(find.text('In progress'));
await t.tap(find.bySemanticsLabel('In progress'));
await t.pump();
expect((await repo.listNotes()).single.status, Status.inProgress);
expect(find.text('Delete note'), findsOneWidget);
expect(find.bySemanticsLabel('Delete note'), findsOneWidget);
});
testWidgets('a title-only note adds no body to the sheet', (t) async {
@ -142,9 +142,9 @@ void main() {
final repo = await pumpList(t, seed: [note('a', 'Delete me')]);
await _openSheet(t, 'Delete me');
await t.tap(find.text('Delete note'));
await t.tap(find.bySemanticsLabel('Delete note'));
await t.pumpAndSettle();
await t.tap(find.text('Delete'));
await t.tap(find.byKey(const ValueKey('confirm-delete')));
await t.pumpAndSettle();
expect(await repo.listNotes(), isEmpty);

View File

@ -10,7 +10,7 @@ import 'notes_list_harness.dart';
/// Opens a date field's menu and picks [entry].
Future<void> pickRange(WidgetTester tester, String field, String entry) async {
await tester.tap(find.byTooltip(field));
await tester.tap(find.bySemanticsLabel(field));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text(entry).last);
@ -82,9 +82,9 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Done')); // add a non-default status
await tester.tap(find.bySemanticsLabel('Done')); // non-default status
await tester.pump();
await tester.tap(find.text('Clear all'));
await tester.tap(find.bySemanticsLabel('Clear all'));
await tester.pump();
await tapApply(tester);
await tester.pump();
@ -139,7 +139,7 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('High')); // priority chip toggle
await tester.tap(find.bySemanticsLabel('High')); // priority chip toggle
await pickRange(tester, 'Last updated', 'Today');
await tapApply(tester);
await tester.pump();
@ -178,10 +178,10 @@ void main() {
testWidgets('per-note sheet changes priority via a chip', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Repriortise me')]);
await tester.tap(find.byTooltip('Quick actions'));
await tester.tap(find.bySemanticsLabel('Quick actions'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Low')); // default is Medium → change to Low
await tester.tap(find.bySemanticsLabel('Low')); // Medium → Low
await tester.pump();
expect((await repo.listNotes()).single.priority, Priority.low);
@ -196,7 +196,7 @@ void main() {
// The detail screen shows the note title in its app bar plus the
// Priority/Status meta dropdowns and the editor mode toggle.
expect(find.text('Priority'), findsOneWidget);
expect(find.text('View'), findsOneWidget);
expect(find.bySemanticsLabel('Priority'), findsOneWidget);
expect(find.bySemanticsLabel('View'), findsOneWidget);
});
}

View File

@ -37,7 +37,7 @@ void main() {
) async {
await pumpList(tester, seed: [note('a', 'Buy milk')]);
await editNoteAndSwipeBack(tester);
expect(find.text('Search notes…'), findsOneWidget);
expect(find.byIcon(Icons.search), findsOneWidget);
expect(textFieldFocused(), isFalse);
});
@ -48,7 +48,7 @@ void main() {
await tester.tap(find.byType(TextField).first);
await tester.pump();
await editNoteAndSwipeBack(tester);
expect(find.text('Search notes…'), findsOneWidget);
expect(find.byIcon(Icons.search), findsOneWidget);
expect(textFieldFocused(), isFalse);
});
}

View File

@ -53,7 +53,8 @@ Future<FakeNoteRepository> pumpList(
/// sheet, so on a short viewport it legitimately sits below the fold:
/// scroll it into view first, as a user would.
Future<void> tapApply(WidgetTester tester) async {
await tester.ensureVisible(find.text('Apply'));
final apply = find.widgetWithIcon(FilledButton, Icons.check);
await tester.ensureVisible(apply);
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.tap(apply);
}

View File

@ -79,7 +79,10 @@ void main() {
if (axisDirectionToAxis(s.axisDirection) == Axis.vertical)
expect(s.position.maxScrollExtent, 0);
}
expect(find.text('Apply').hitTestable(), findsOneWidget);
expect(
find.widgetWithIcon(FilledButton, Icons.check).hitTestable(),
findsOneWidget,
);
expect(find.text('Repo tasks'), findsNothing);
expect(find.text('Ideas'), findsNothing);
expect(find.byType(ActionChip), findsNothing);

View File

@ -51,7 +51,7 @@ void main() {
await tester.tap(find.byIcon(Icons.sort));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // menu open
await tester.tap(find.text('Alphabetical').last);
await tester.tap(find.bySemanticsLabel('Alphabetical').last);
await tester.pump();
expect(repo.lastSort, NoteSort.alphabetical);
@ -63,9 +63,9 @@ void main() {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // sheet open
await tester.tap(find.text('Done'));
await tester.tap(find.bySemanticsLabel('Done'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tapApply(tester);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // sheet close
@ -77,16 +77,16 @@ void main() {
testWidgets('per-note sheet deletes the note', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Delete me')]);
await tester.tap(find.byTooltip('Quick actions'));
await tester.tap(find.bySemanticsLabel('Quick actions'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // sheet open
await tester.tap(find.text('Delete note'));
await tester.tap(find.bySemanticsLabel('Delete note'));
await tester.pumpAndSettle();
// Delete is now confirmed on this path too: it is irreversible, has no
// undo, and is one Tab from a Return in the sheet's chip rows.
expect(find.text('Delete note?'), findsOneWidget);
await tester.tap(find.text('Delete'));
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
await tester.pumpAndSettle();
expect(await repo.listNotes(), isEmpty);
@ -95,12 +95,12 @@ void main() {
testWidgets('cancelling the sheet delete keeps the note', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Keep me')]);
await tester.tap(find.byTooltip('Quick actions'));
await tester.tap(find.bySemanticsLabel('Quick actions'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Delete note'));
await tester.tap(find.bySemanticsLabel('Delete note'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.tap(find.byKey(const ValueKey('confirm-cancel')));
await tester.pumpAndSettle();
expect(await repo.listNotes(), hasLength(1));
@ -109,10 +109,10 @@ void main() {
testWidgets('per-note sheet changes status via a chip', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Change me')]);
await tester.tap(find.byTooltip('Quick actions'));
await tester.tap(find.bySemanticsLabel('Quick actions'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('In progress'));
await tester.tap(find.bySemanticsLabel('In progress'));
await tester.pump();
expect((await repo.listNotes()).single.status, Status.inProgress);

View File

@ -96,7 +96,7 @@ void main() {
],
);
await tester.tap(find.text('Sync settings'));
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
// _export does real file I/O on desktop, so drive it under runAsync.
@ -116,7 +116,7 @@ void main() {
) async {
await pumpSettings(tester, repository: _ExplodingRepo());
await tester.tap(find.text('Sync settings'));
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
await tester.runAsync(() async {
@ -149,7 +149,7 @@ void main() {
final repo = await pumpSettings(tester);
await tester.tap(find.text('Sync settings'));
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
await tester.tap(find.text('Import notes'));
@ -165,7 +165,7 @@ void main() {
FileSelectorPlatform.instance = _FakeFileSelector(null); // cancelled
final repo = await pumpSettings(tester);
await tester.tap(find.text('Sync settings'));
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
await tester.tap(find.text('Import notes'));
@ -183,7 +183,7 @@ void main() {
FileSelectorPlatform.instance = _ThrowingFileSelector();
final repo = await pumpSettings(tester);
await tester.tap(find.text('Sync settings'));
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
await tester.tap(find.text('Import notes'));

View File

@ -61,30 +61,25 @@ class _ThrowingFileSelector extends FileSelectorPlatform
}
void main() {
testWidgets(
'Enable advanced toggles and persists locally with no client',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: false),
);
await pumpSettings(
tester,
appSettings: appSettings,
firebaseFactory: () async => null,
);
await tester.tap(find.text('Enable advanced'));
await tester.pump();
expect(appSettings.value.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
testWidgets('Enable advanced mirrors the toggle to Firebase', (
testWidgets('Enable advanced toggles and persists locally with no client', (
tester,
) async {
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
await pumpSettings(
tester,
appSettings: appSettings,
firebaseFactory: () async => null,
);
await tester.tap(find.byIcon(Icons.tune));
await tester.pump();
expect(appSettings.value.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
});
testWidgets('Enable advanced mirrors the toggle to Firebase', (tester) async {
String? putPath;
final firebase = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
@ -110,7 +105,7 @@ void main() {
firebaseFactory: () async => firebase,
);
await tester.tap(find.text('Enable advanced'));
await tester.tap(find.byIcon(Icons.tune));
await tester.pump();
expect(appSettings.value.advancedMode, isTrue);
@ -120,16 +115,14 @@ void main() {
testWidgets(
'Enable advanced still persists locally when opening Firebase throws',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: false),
);
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
await pumpSettings(
tester,
appSettings: appSettings,
firebaseFactory: () async => throw FirebaseAuthError('wrong account'),
);
await tester.tap(find.text('Enable advanced'));
await tester.tap(find.byIcon(Icons.tune));
await tester.pump();
// A signed-in-as-wrong-account device must not lose the local toggle.
@ -137,9 +130,7 @@ void main() {
},
);
testWidgets('Enable advanced logs a settings_toggle event', (
tester,
) async {
testWidgets('Enable advanced logs a settings_toggle event', (tester) async {
const analytics = AnalyticsService(nodeId: 'device-a');
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
await pumpSettings(
@ -149,7 +140,7 @@ void main() {
firebaseFactory: () async => null,
);
await tester.tap(find.text('Enable advanced'));
await tester.tap(find.byIcon(Icons.tune));
await tester.pump();
final prefs = await SharedPreferences.getInstance();
@ -162,9 +153,9 @@ void main() {
await pumpSettings(tester);
// The slim screen only links out; neither sync surface's own widgets
// should be inlined here anymore.
expect(find.text('Sync settings'), findsOneWidget);
expect(find.text('Advanced sync (GitHub)'), findsOneWidget);
expect(find.text('Connect GitHub'), findsNothing);
expect(find.byIcon(Icons.sync), findsOneWidget);
expect(find.byIcon(Icons.cloud_sync), findsOneWidget);
expect(find.bySemanticsLabel('Connect GitHub'), findsNothing);
expect(find.text('Sign in with Google'), findsNothing);
});
@ -173,7 +164,7 @@ void main() {
) async {
await pumpSettings(tester);
await tester.tap(find.text('Sync settings'));
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
expect(find.byType(SyncSettingsScreen), findsOneWidget);
@ -186,7 +177,7 @@ void main() {
const initial = SyncSettings(owner: 'o', repo: 'r', token: 'tok');
final repo = await pumpSettings(tester, initial: initial);
await tester.tap(find.text('Advanced sync (GitHub)'));
await tester.tap(find.byIcon(Icons.cloud_sync));
await tester.pumpAndSettle();
expect(find.byType(GitHubMirrorScreen), findsOneWidget);

View File

@ -15,11 +15,11 @@ void main() {
),
),
);
await tester.tap(find.byTooltip('Attach'));
await tester.tap(find.bySemanticsLabel('Attach'));
await tester.pump();
expect(got, isEmpty);
result = [XFile('/picked/a.pdf')];
await tester.tap(find.byTooltip('Attach'));
await tester.tap(find.bySemanticsLabel('Attach'));
await tester.pump();
expect(got.single.single.name, 'a.pdf');
});
@ -55,7 +55,7 @@ void main() {
Future<void> choose(WidgetTester tester, String entry) async {
await tester.tap(find.text('pick'));
await tester.pumpAndSettle();
await tester.tap(find.text(entry));
await tester.tap(find.bySemanticsLabel(entry));
await tester.pumpAndSettle();
}
@ -64,9 +64,9 @@ void main() {
) async {
final asked = <AttachmentPickSource>[];
await openSheet(tester, asked);
expect(find.text('Camera'), findsOneWidget);
expect(find.text('File'), findsOneWidget);
await tester.tap(find.text('Gallery'));
expect(find.bySemanticsLabel('Camera'), findsOneWidget);
expect(find.bySemanticsLabel('File'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('Gallery'));
await tester.pumpAndSettle();
await choose(tester, 'Camera');
await choose(tester, 'File');

View File

@ -25,7 +25,7 @@ void main() {
],
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
await tester.tap(find.byTooltip('Attach'));
await tester.tap(find.bySemanticsLabel('Attach'));
await tester.pump();
await tester.pump();
expect(store.attachedFiles, hasLength(2));
@ -47,7 +47,7 @@ void main() {
await tester.enterText(find.byType(TextField), 'idea');
await tester.pump();
final id = (await repo.listNotes()).single.id;
await tester.tap(find.byTooltip('Attach'));
await tester.tap(find.bySemanticsLabel('Attach'));
await tester.pump();
await tester.pump();
final text = (await repo.listNotes()).single.text;
@ -90,7 +90,7 @@ void main() {
);
await tester.pump();
final before = store.reconciles.length;
await tester.tap(find.byTooltip('Sync'));
await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(store.reconciles.length, greaterThan(before));

View File

@ -33,7 +33,7 @@ void main() {
Future<void> saveWith(WidgetTester tester, String password) async {
await tester.enterText(find.byType(TextField), password);
await tester.tap(find.text('Save & test'));
await tester.tap(find.bySemanticsLabel('Save & test'));
await tester.pump();
await tester.pump();
}
@ -43,7 +43,7 @@ void main() {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: CloudImagesTile())),
);
await tester.tap(find.text('Cloud images'));
await tester.tap(find.bySemanticsLabel('Cloud images'));
await tester.pumpAndSettle();
expect(find.byType(CloudImagesScreen), findsOneWidget);
});
@ -55,8 +55,8 @@ void main() {
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'saved',
);
expect(find.text('Login: configured'), findsOneWidget);
expect(find.text('Waiting to upload: 0'), findsOneWidget);
expect(find.bySemanticsLabel('Login: configured'), findsOneWidget);
expect(find.bySemanticsLabel('Waiting to upload: 0'), findsOneWidget);
});
testWidgets('an accepted password is saved and the queue uploads', (
@ -108,7 +108,7 @@ void main() {
await pump(tester, field: false);
expect(find.byType(TextField), findsNothing);
expect(
find.text('Login: missing — create it on the PC with'),
find.bySemanticsLabel('Login: missing — create it on the PC with'),
findsOneWidget,
);
expect(find.text(kAddLoginCommand), findsOneWidget);
@ -120,7 +120,7 @@ void main() {
final store = FakeImageStore();
await pump(tester, store: store, client: MockClient((_) => reply.future));
await tester.enterText(find.byType(TextField), 'pw');
await tester.tap(find.text('Save & test'));
await tester.tap(find.bySemanticsLabel('Save & test'));
await tester.pump();
await tester.pumpWidget(const SizedBox());
reply.complete(http.Response('', 207));

View File

@ -58,7 +58,7 @@ void main() {
await tester.pump();
tester.widget<TextField>(find.byType(TextField)).controller!.selection =
const TextSelection.collapsed(offset: 5);
await tester.tap(find.byTooltip('Attach'));
await tester.tap(find.bySemanticsLabel('Attach'));
await tester.pump();
await tester.pump();
expect(
@ -81,7 +81,7 @@ void main() {
await tester.pump();
expect((await repo.listNotes()).single.text, contains('[Raport.pdf]('));
// View mode renders inline and hides the strip.
await tester.tap(find.text('View'));
await tester.tap(find.bySemanticsLabel('View'));
await tester.pump();
expect(find.byType(AttachmentStrip), findsNothing);
expect(find.byType(ImageEmbed), findsNWidgets(2));

View File

@ -88,7 +88,8 @@ void main() {
template: NoteTemplate.llmDesignSpec,
mode: NoteEditorMode.guided,
);
expect(find.text('Exit guided'), findsNothing);
// In the guided stepper: its exit arrow is present.
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
attach.insert(['IMG']);
await tester.pump();
expect(field(tester).text, 'IMG\n');
@ -103,7 +104,7 @@ void main() {
template: NoteTemplate.blank,
onModeChanged: modes.add,
);
await tester.tap(find.text('View'));
await tester.tap(find.bySemanticsLabel('View'));
await tester.pump();
expect(modes, [NoteEditorMode.preview]);
attach.insert(['IMG']);