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 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
@ -37,6 +38,8 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
final _screenFocus = FocusNode();
|
final _screenFocus = FocusNode();
|
||||||
final _sourceScroll = ScrollController();
|
final _sourceScroll = ScrollController();
|
||||||
final _previewScroll = ScrollController();
|
final _previewScroll = ScrollController();
|
||||||
|
Timer? _centerHold;
|
||||||
|
final _menuKey = GlobalKey<PopupMenuButtonState<String>>();
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
bool _isPreviewMode = false;
|
bool _isPreviewMode = false;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
@ -154,6 +157,19 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
if (await _onWillPop() && mounted) Navigator.pop(context);
|
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 {
|
Future<void> _quit() async {
|
||||||
if (await _onWillPop()) await widget.onQuit();
|
if (await _onWillPop()) await widget.onQuit();
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +209,11 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
primary(LogicalKeyboardKey.numpadSubtract): () => _stepFontSize(-2),
|
primary(LogicalKeyboardKey.numpadSubtract): () => _stepFontSize(-2),
|
||||||
primary(LogicalKeyboardKey.digit0): () => _updateFontSize(16),
|
primary(LogicalKeyboardKey.digit0): () => _updateFontSize(16),
|
||||||
// In edit mode the arrows move the caret instead.
|
// 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) ...{
|
if (_isPreviewMode || !_isEditing) ...{
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () => _scroll(3),
|
const SingleActivator(LogicalKeyboardKey.arrowDown): () => _scroll(3),
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () => _scroll(-3),
|
const SingleActivator(LogicalKeyboardKey.arrowUp): () => _scroll(-3),
|
||||||
|
|
@ -273,6 +294,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
_screenFocus.dispose();
|
_screenFocus.dispose();
|
||||||
_sourceScroll.dispose();
|
_sourceScroll.dispose();
|
||||||
_previewScroll.dispose();
|
_previewScroll.dispose();
|
||||||
|
_centerHold?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +309,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
child: Focus(
|
child: Focus(
|
||||||
focusNode: _screenFocus,
|
focusNode: _screenFocus,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
onKeyEvent: _onCenterKey,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')),
|
title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')),
|
||||||
|
|
@ -307,6 +330,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<String>(
|
||||||
|
key: _menuKey,
|
||||||
icon: SvgPicture.asset(
|
icon: SvgPicture.asset(
|
||||||
"assets/blaster.svg",
|
"assets/blaster.svg",
|
||||||
width: 30,
|
width: 30,
|
||||||
|
|
@ -364,18 +388,26 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||||
Text('Font Size'),
|
Text('Font Size'),
|
||||||
StatefulBuilder(
|
StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
return Slider(
|
// Directional mode leaves Up/Down to the D-pad, so
|
||||||
value: _fontSize,
|
// focus can move past the slider; Left/Right adjust it.
|
||||||
min: 12.0,
|
return MediaQuery(
|
||||||
max: 24.0,
|
data: MediaQuery.of(context).copyWith(
|
||||||
divisions: 6,
|
navigationMode: dpadNavigation
|
||||||
label: _fontSize.toString(),
|
? NavigationMode.directional
|
||||||
onChanged: (newSize) {
|
: NavigationMode.traditional),
|
||||||
setState(() {
|
child: Slider(
|
||||||
_fontSize = newSize;
|
value: _fontSize,
|
||||||
});
|
min: 12.0,
|
||||||
_updateFontSize(newSize);
|
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;
|
Timer? _debounce;
|
||||||
int? _highlighted; // Note picked with the arrow keys, opened by Enter.
|
int? _highlighted; // Note picked with the arrow keys, opened by Enter.
|
||||||
final _highlightedKey = GlobalKey();
|
final _highlightedKey = GlobalKey();
|
||||||
|
final _listFocus = FocusNode();
|
||||||
|
final _menuKey = GlobalKey<PopupMenuButtonState<String>>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -73,6 +75,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
searchController.dispose();
|
searchController.dispose();
|
||||||
|
_listFocus.dispose();
|
||||||
super.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() {
|
void _focusSearch() {
|
||||||
searchFocusNode.requestFocus();
|
searchFocusNode.requestFocus();
|
||||||
searchController.selection = TextSelection(
|
searchController.selection = TextSelection(
|
||||||
|
|
@ -415,6 +450,7 @@ Happy note taking! ✨
|
||||||
print("❌ Failed to open external editor: $e");
|
print("❌ Failed to open external editor: $e");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
final fromList = _listFocus.hasFocus;
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -425,9 +461,15 @@ Happy note taking! ✨
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_loadNotes(); // ✅ Reload notes after returning
|
await _loadNotes(); // ✅ Reload notes after returning
|
||||||
setState(() {}); // ✅ Force UI refresh
|
if (!mounted) return;
|
||||||
searchFocusNode.requestFocus();
|
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(
|
title: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||||
child: CallbackShortcuts(
|
child: CallbackShortcuts(
|
||||||
bindings: {
|
bindings: dpadNavigation
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
? {
|
||||||
_moveHighlight(1),
|
const SingleActivator(LogicalKeyboardKey.arrowDown):
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
_enterList,
|
||||||
_moveHighlight(-1),
|
const SingleActivator(LogicalKeyboardKey.arrowRight):
|
||||||
},
|
_pressBlaster,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||||
|
_moveHighlight(1),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||||
|
_moveHighlight(-1),
|
||||||
|
},
|
||||||
child: TextField(
|
child: TextField(
|
||||||
focusNode: searchFocusNode,
|
focusNode: searchFocusNode,
|
||||||
enableInteractiveSelection: true,
|
enableInteractiveSelection: true,
|
||||||
|
|
@ -794,6 +843,7 @@ Happy note taking! ✨
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<String>(
|
||||||
|
key: _menuKey,
|
||||||
enabled: searchController.text.isEmpty,
|
enabled: searchController.text.isEmpty,
|
||||||
icon: SvgPicture.asset(
|
icon: SvgPicture.asset(
|
||||||
"assets/blaster.svg",
|
"assets/blaster.svg",
|
||||||
|
|
@ -805,6 +855,12 @@ Happy note taking! ✨
|
||||||
case 'Toggle Tags':
|
case 'Toggle Tags':
|
||||||
widget.toggleTags();
|
widget.toggleTags();
|
||||||
break;
|
break;
|
||||||
|
case 'Include Subdirectories':
|
||||||
|
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||||
|
break;
|
||||||
|
case 'Include File Content':
|
||||||
|
_toggleIncludeFileContent(!_includeFileContent);
|
||||||
|
break;
|
||||||
case 'Toggle Theme':
|
case 'Toggle Theme':
|
||||||
setState(() {
|
setState(() {
|
||||||
widget.toggleTheme(!widget.isDarkMode);
|
widget.toggleTheme(!widget.isDarkMode);
|
||||||
|
|
@ -857,10 +913,6 @@ Happy note taking! ✨
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
||||||
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
|
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
|
||||||
onTap: () {
|
|
||||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
|
||||||
Navigator.pop(context); // Close menu after toggle
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
|
|
@ -872,10 +924,6 @@ Happy note taking! ✨
|
||||||
title: Text(
|
title: Text(
|
||||||
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
|
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
|
||||||
),
|
),
|
||||||
onTap: () {
|
|
||||||
_toggleIncludeFileContent(!_includeFileContent);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
|
|
@ -932,7 +980,20 @@ Happy note taking! ✨
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: EdgeInsets.only(left: 10),
|
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) {
|
onNotification: (ScrollNotification scrollInfo) {
|
||||||
if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 100) {
|
if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 100) {
|
||||||
_loadMoreNotes(); // ✅ Lazy load when user scrolls
|
_loadMoreNotes(); // ✅ Lazy load when user scrolls
|
||||||
|
|
@ -1007,6 +1068,8 @@ Happy note taking! ✨
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.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.
|
/// [key] with Cmd on Apple platforms and Ctrl elsewhere.
|
||||||
SingleActivator primaryShortcut(LogicalKeyboardKey key, {bool shift = false}) {
|
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);
|
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);
|
final quitShortcut = primaryShortcut(LogicalKeyboardKey.keyQ);
|
||||||
|
|
||||||
Future<void> quitApp() =>
|
Future<void> quitApp() =>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
|
path_provider_platform_interface: ^2.1.3
|
||||||
|
plugin_platform_interface: ^2.1.8
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,115 @@ void main() {
|
||||||
expect(find.byType(NoteEditorScreen), findsNothing);
|
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 {
|
testWidgets('the open note follows theme switches', (tester) async {
|
||||||
final mode = ValueNotifier(ThemeMode.light);
|
final mode = ValueNotifier(ThemeMode.light);
|
||||||
await tester.runAsync(() async {
|
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