feat: add D-pad navigation for phones
This commit is contained in:
parent
414f8e8484
commit
b3df7fba9f
6 changed files with 380 additions and 31 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
|
@ -37,6 +38,8 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
final _screenFocus = FocusNode();
|
||||
final _sourceScroll = ScrollController();
|
||||
final _previewScroll = ScrollController();
|
||||
Timer? _centerHold;
|
||||
final _menuKey = GlobalKey<PopupMenuButtonState<String>>();
|
||||
bool _isEditing = false;
|
||||
bool _isPreviewMode = false;
|
||||
bool _isLoading = true;
|
||||
|
|
@ -154,6 +157,19 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
if (await _onWillPop() && mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// Holding the D-pad center button goes back to the list.
|
||||
KeyEventResult _onCenterKey(FocusNode node, KeyEvent event) {
|
||||
if (event.logicalKey != LogicalKeyboardKey.select) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
if (event is KeyDownEvent) {
|
||||
_centerHold = Timer(const Duration(milliseconds: 500), _backToList);
|
||||
} else if (event is KeyUpEvent) {
|
||||
_centerHold?.cancel();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
Future<void> _quit() async {
|
||||
if (await _onWillPop()) await widget.onQuit();
|
||||
}
|
||||
|
|
@ -193,6 +209,11 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
primary(LogicalKeyboardKey.numpadSubtract): () => _stepFontSize(-2),
|
||||
primary(LogicalKeyboardKey.digit0): () => _updateFontSize(16),
|
||||
// In edit mode the arrows move the caret instead.
|
||||
if ((_isPreviewMode || !_isEditing) && dpadNavigation) ...{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): _backToList,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
||||
openMenuWithFocus(_menuKey),
|
||||
},
|
||||
if (_isPreviewMode || !_isEditing) ...{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () => _scroll(3),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () => _scroll(-3),
|
||||
|
|
@ -273,6 +294,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
_screenFocus.dispose();
|
||||
_sourceScroll.dispose();
|
||||
_previewScroll.dispose();
|
||||
_centerHold?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +309,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
child: Focus(
|
||||
focusNode: _screenFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onCenterKey,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')),
|
||||
|
|
@ -307,6 +330,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
child: PopupMenuButton<String>(
|
||||
key: _menuKey,
|
||||
icon: SvgPicture.asset(
|
||||
"assets/blaster.svg",
|
||||
width: 30,
|
||||
|
|
@ -364,18 +388,26 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
Text('Font Size'),
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Slider(
|
||||
value: _fontSize,
|
||||
min: 12.0,
|
||||
max: 24.0,
|
||||
divisions: 6,
|
||||
label: _fontSize.toString(),
|
||||
onChanged: (newSize) {
|
||||
setState(() {
|
||||
_fontSize = newSize;
|
||||
});
|
||||
_updateFontSize(newSize);
|
||||
},
|
||||
// Directional mode leaves Up/Down to the D-pad, so
|
||||
// focus can move past the slider; Left/Right adjust it.
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
navigationMode: dpadNavigation
|
||||
? NavigationMode.directional
|
||||
: NavigationMode.traditional),
|
||||
child: Slider(
|
||||
value: _fontSize,
|
||||
min: 12.0,
|
||||
max: 24.0,
|
||||
divisions: 6,
|
||||
label: _fontSize.toString(),
|
||||
onChanged: (newSize) {
|
||||
setState(() {
|
||||
_fontSize = newSize;
|
||||
});
|
||||
_updateFontSize(newSize);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
Timer? _debounce;
|
||||
int? _highlighted; // Note picked with the arrow keys, opened by Enter.
|
||||
final _highlightedKey = GlobalKey();
|
||||
final _listFocus = FocusNode();
|
||||
final _menuKey = GlobalKey<PopupMenuButtonState<String>>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -73,6 +75,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
searchController.dispose();
|
||||
_listFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +390,38 @@ Happy note taking! ✨
|
|||
});
|
||||
}
|
||||
|
||||
void _enterList() {
|
||||
if (filteredNotes.isEmpty) return;
|
||||
_listFocus.requestFocus();
|
||||
if (_highlighted == null) _moveHighlight(1);
|
||||
}
|
||||
|
||||
void _leaveList() {
|
||||
setState(() => _highlighted = null);
|
||||
searchFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _listUp() {
|
||||
if ((_highlighted ?? 0) == 0) {
|
||||
_leaveList();
|
||||
} else {
|
||||
_moveHighlight(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Acts like tapping the blaster: clears the search, or opens the menu.
|
||||
void _pressBlaster() {
|
||||
if (searchFocusNode.hasFocus && searchController.text.isNotEmpty) {
|
||||
searchController.clear();
|
||||
return;
|
||||
}
|
||||
openMenuWithFocus(_menuKey);
|
||||
}
|
||||
|
||||
void _openHighlighted() {
|
||||
if (_highlighted != null) _openNote(filteredNotes[_highlighted!]);
|
||||
}
|
||||
|
||||
void _focusSearch() {
|
||||
searchFocusNode.requestFocus();
|
||||
searchController.selection = TextSelection(
|
||||
|
|
@ -415,6 +450,7 @@ Happy note taking! ✨
|
|||
print("❌ Failed to open external editor: $e");
|
||||
}
|
||||
} else {
|
||||
final fromList = _listFocus.hasFocus;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -425,9 +461,15 @@ Happy note taking! ✨
|
|||
),
|
||||
),
|
||||
);
|
||||
_loadNotes(); // ✅ Reload notes after returning
|
||||
setState(() {}); // ✅ Force UI refresh
|
||||
searchFocusNode.requestFocus();
|
||||
await _loadNotes(); // ✅ Reload notes after returning
|
||||
if (!mounted) return;
|
||||
final index = filteredNotes.indexWhere((n) => n.path == noteFile.path);
|
||||
if (fromList && index != -1) {
|
||||
setState(() => _highlighted = index);
|
||||
_listFocus.requestFocus();
|
||||
} else {
|
||||
searchFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -751,12 +793,19 @@ Happy note taking! ✨
|
|||
title: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||
_moveHighlight(1),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||
_moveHighlight(-1),
|
||||
},
|
||||
bindings: dpadNavigation
|
||||
? {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown):
|
||||
_enterList,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight):
|
||||
_pressBlaster,
|
||||
}
|
||||
: {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||
_moveHighlight(1),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||
_moveHighlight(-1),
|
||||
},
|
||||
child: TextField(
|
||||
focusNode: searchFocusNode,
|
||||
enableInteractiveSelection: true,
|
||||
|
|
@ -794,6 +843,7 @@ Happy note taking! ✨
|
|||
}
|
||||
},
|
||||
child: PopupMenuButton<String>(
|
||||
key: _menuKey,
|
||||
enabled: searchController.text.isEmpty,
|
||||
icon: SvgPicture.asset(
|
||||
"assets/blaster.svg",
|
||||
|
|
@ -805,6 +855,12 @@ Happy note taking! ✨
|
|||
case 'Toggle Tags':
|
||||
widget.toggleTags();
|
||||
break;
|
||||
case 'Include Subdirectories':
|
||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||
break;
|
||||
case 'Include File Content':
|
||||
_toggleIncludeFileContent(!_includeFileContent);
|
||||
break;
|
||||
case 'Toggle Theme':
|
||||
setState(() {
|
||||
widget.toggleTheme(!widget.isDarkMode);
|
||||
|
|
@ -857,10 +913,6 @@ Happy note taking! ✨
|
|||
child: ListTile(
|
||||
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
||||
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
|
||||
onTap: () {
|
||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||
Navigator.pop(context); // Close menu after toggle
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
|
|
@ -872,10 +924,6 @@ Happy note taking! ✨
|
|||
title: Text(
|
||||
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
|
||||
),
|
||||
onTap: () {
|
||||
_toggleIncludeFileContent(!_includeFileContent);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
|
|
@ -932,7 +980,20 @@ Happy note taking! ✨
|
|||
),
|
||||
body: Padding(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||
_moveHighlight(1),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): _listUp,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): _leaveList,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight):
|
||||
_pressBlaster,
|
||||
const SingleActivator(LogicalKeyboardKey.select): _openHighlighted,
|
||||
const SingleActivator(LogicalKeyboardKey.enter): _openHighlighted,
|
||||
},
|
||||
child: Focus(
|
||||
focusNode: _listFocus,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (ScrollNotification scrollInfo) {
|
||||
if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 100) {
|
||||
_loadMoreNotes(); // ✅ Lazy load when user scrolls
|
||||
|
|
@ -1007,6 +1068,8 @@ Happy note taking! ✨
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// [key] with Cmd on Apple platforms and Ctrl elsewhere.
|
||||
SingleActivator primaryShortcut(LogicalKeyboardKey key, {bool shift = false}) {
|
||||
|
|
@ -10,6 +10,21 @@ SingleActivator primaryShortcut(LogicalKeyboardKey key, {bool shift = false}) {
|
|||
return SingleActivator(key, control: !apple, meta: apple, shift: shift);
|
||||
}
|
||||
|
||||
/// Phones with a D-pad move focus between screen areas with the arrow keys,
|
||||
/// so the soft keyboard closes while browsing.
|
||||
bool get dpadNavigation =>
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
/// Opens the popup menu behind [key] with its first item focused, so the
|
||||
/// D-pad can move through it.
|
||||
void openMenuWithFocus(GlobalKey<PopupMenuButtonState<String>> key) {
|
||||
key.currentState?.showButtonMenu();
|
||||
// The menu takes focus in a microtask after its first frame, so wait for that.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) =>
|
||||
Future(() => FocusManager.instance.primaryFocus?.nextFocus()));
|
||||
}
|
||||
|
||||
final quitShortcut = primaryShortcut(LogicalKeyboardKey.keyQ);
|
||||
|
||||
Future<void> quitApp() =>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ dev_dependencies:
|
|||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
path_provider_platform_interface: ^2.1.3
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
|
|
|||
|
|
@ -172,6 +172,115 @@ void main() {
|
|||
expect(find.byType(NoteEditorScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('holding the D-pad center goes back to the list',
|
||||
(tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NoteEditorScreen(
|
||||
note: note, notesDirectoryPath: note.parent.path),
|
||||
),
|
||||
),
|
||||
child: const Text('list'),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.text('list'));
|
||||
await tester.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.select);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NoteEditorScreen), findsOneWidget,
|
||||
reason: 'a short press stays in the note');
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.select);
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NoteEditorScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('D-pad Right opens the menu and Left goes back to the list',
|
||||
(tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NoteEditorScreen(
|
||||
note: note, notesDirectoryPath: note.parent.path),
|
||||
),
|
||||
),
|
||||
child: const Text('list'),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.tap(find.text('list'));
|
||||
await tester.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
expect(find.text('Save'), findsOneWidget);
|
||||
// Share has focus first; Down moves to Save, which closes the menu.
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
await _press(tester, LogicalKeyboardKey.select);
|
||||
expect(find.text('Save'), findsNothing);
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.keyE, control: true);
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
expect(find.text('Save'), findsNothing,
|
||||
reason: 'arrows move the caret in edit mode');
|
||||
await _press(tester, LogicalKeyboardKey.arrowLeft);
|
||||
expect(find.byType(NoteEditorScreen), findsOneWidget);
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.keyE, control: true);
|
||||
await _press(tester, LogicalKeyboardKey.arrowLeft);
|
||||
expect(find.byType(NoteEditorScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the D-pad moves past the font size slider in the menu',
|
||||
(tester) async {
|
||||
await pumpEditor(tester);
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
|
||||
String focusedLabel() {
|
||||
final context = FocusManager.instance.primaryFocus!.context!;
|
||||
if (context.findAncestorWidgetOfExactType<Slider>() != null ||
|
||||
context.widget is Slider) {
|
||||
return 'slider';
|
||||
}
|
||||
final texts = find.descendant(
|
||||
of: find.byWidget(context.widget), matching: find.byType(Text));
|
||||
return tester.widgetList<Text>(texts).map((t) => t.data).join();
|
||||
}
|
||||
|
||||
// Share, Save, Autosave, slider, Preview.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
}
|
||||
expect(focusedLabel(), 'slider');
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
expect(tester.widget<MarkdownView>(find.byType(MarkdownView)).fontSize, 18,
|
||||
reason: 'Right adjusts the slider');
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
expect(focusedLabel(), 'Preview Mode');
|
||||
});
|
||||
|
||||
testWidgets('the open note follows theme switches', (tester) async {
|
||||
final mode = ValueNotifier(ThemeMode.light);
|
||||
await tester.runAsync(() async {
|
||||
|
|
|
|||
128
test/note_list_dpad_test.dart
Normal file
128
test/note_list_dpad_test.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:muzzle_velocity/note_editor.dart';
|
||||
import 'package:muzzle_velocity/note_list.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.path);
|
||||
final String path;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => path;
|
||||
}
|
||||
|
||||
Future<void> _press(WidgetTester tester, LogicalKeyboardKey key) async {
|
||||
await tester.sendKeyEvent(key);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory notes;
|
||||
|
||||
setUp(() {
|
||||
notes = Directory.systemTemp.createTempSync('mv-list');
|
||||
for (final name in ['alpha', 'beta', 'gamma']) {
|
||||
File('${notes.path}/$name.md').writeAsStringSync('# $name\n');
|
||||
}
|
||||
PathProviderPlatform.instance = _FakePathProvider(notes.path);
|
||||
SharedPreferences.setMockInitialValues({'initialized': true});
|
||||
});
|
||||
|
||||
Future<void> pumpList(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: NoteListScreen(
|
||||
toggleTheme: (_) {},
|
||||
toggleTags: () {},
|
||||
isDarkMode: false,
|
||||
showTags: true,
|
||||
),
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
bool searchHasFocus(WidgetTester tester) =>
|
||||
tester.widget<TextField>(find.byType(TextField)).focusNode!.hasFocus;
|
||||
|
||||
List<String> highlighted(WidgetTester tester) => [
|
||||
for (final tile in tester.widgetList<ListTile>(find.byType(ListTile)))
|
||||
if (tile.selected) (tile.title as Text).data!,
|
||||
];
|
||||
|
||||
testWidgets('Down enters the list, Up and Left return to the search field',
|
||||
(tester) async {
|
||||
await pumpList(tester);
|
||||
expect(searchHasFocus(tester), isTrue);
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
expect(searchHasFocus(tester), isFalse);
|
||||
expect(highlighted(tester), hasLength(1));
|
||||
final first = highlighted(tester).single;
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
expect(highlighted(tester).single, isNot(first));
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowUp);
|
||||
expect(highlighted(tester).single, first);
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowUp);
|
||||
expect(searchHasFocus(tester), isTrue);
|
||||
expect(highlighted(tester), isEmpty);
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
await _press(tester, LogicalKeyboardKey.arrowLeft);
|
||||
expect(searchHasFocus(tester), isTrue);
|
||||
expect(highlighted(tester), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('Right clears the search text, then opens the menu',
|
||||
(tester) async {
|
||||
await pumpList(tester);
|
||||
await tester.enterText(find.byType(TextField), 'alp');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
expect(find.text('alp'), findsNothing);
|
||||
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
expect(find.text('About'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Right in the list opens the menu with D-pad navigation',
|
||||
(tester) async {
|
||||
await pumpList(tester);
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
await _press(tester, LogicalKeyboardKey.arrowRight);
|
||||
expect(find.text('About'), findsOneWidget);
|
||||
|
||||
// The first item has focus, so moving down and selecting picks the second.
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
await _press(tester, LogicalKeyboardKey.select);
|
||||
expect(find.text('About'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('center opens the highlighted note', (tester) async {
|
||||
await pumpList(tester);
|
||||
await _press(tester, LogicalKeyboardKey.arrowDown);
|
||||
// The note reads its file with real I/O, so let it finish between frames.
|
||||
await tester.runAsync(() async {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
});
|
||||
expect(find.byType(NoteEditorScreen), findsOneWidget);
|
||||
final editor = tester.widget<TextField>(find.descendant(
|
||||
of: find.byType(NoteEditorScreen), matching: find.byType(TextField)));
|
||||
expect(editor.controller!.text, matches(r'^# (alpha|beta|gamma)\n$'));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue