feat: add D-pad navigation for phones

This commit is contained in:
randogoth 2026-10-13 11:47:01 +03:00
parent 414f8e8484
commit b3df7fba9f
6 changed files with 380 additions and 31 deletions

View file

@ -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);
},
),
);
},
),

View file

@ -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! ✨
},
),
),
),
),
),
),
);

View file

@ -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() =>

View file

@ -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

View file

@ -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 {

View 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$'));
});
}