From b3df7fba9fc4daba52cd7e5bd609d01344d7c625 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 13 Oct 2026 11:47:01 +0300 Subject: [PATCH] feat: add D-pad navigation for phones --- lib/note_editor.dart | 56 +++++++++--- lib/note_list.dart | 99 +++++++++++++++++---- lib/shortcuts.dart | 17 +++- pubspec.yaml | 2 + test/note_editor_shortcuts_test.dart | 109 +++++++++++++++++++++++ test/note_list_dpad_test.dart | 128 +++++++++++++++++++++++++++ 6 files changed, 380 insertions(+), 31 deletions(-) create mode 100644 test/note_list_dpad_test.dart diff --git a/lib/note_editor.dart b/lib/note_editor.dart index 4604a2a..cdd5545 100644 --- a/lib/note_editor.dart +++ b/lib/note_editor.dart @@ -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 { final _screenFocus = FocusNode(); final _sourceScroll = ScrollController(); final _previewScroll = ScrollController(); + Timer? _centerHold; + final _menuKey = GlobalKey>(); bool _isEditing = false; bool _isPreviewMode = false; bool _isLoading = true; @@ -154,6 +157,19 @@ class _NoteEditorScreenState extends State { 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 _quit() async { if (await _onWillPop()) await widget.onQuit(); } @@ -193,6 +209,11 @@ class _NoteEditorScreenState extends State { 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 { _screenFocus.dispose(); _sourceScroll.dispose(); _previewScroll.dispose(); + _centerHold?.cancel(); super.dispose(); } @@ -287,6 +309,7 @@ class _NoteEditorScreenState extends State { 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 { Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: PopupMenuButton( + key: _menuKey, icon: SvgPicture.asset( "assets/blaster.svg", width: 30, @@ -364,18 +388,26 @@ class _NoteEditorScreenState extends State { 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); + }, + ), ); }, ), diff --git a/lib/note_list.dart b/lib/note_list.dart index c7f30b4..bbba0a9 100644 --- a/lib/note_list.dart +++ b/lib/note_list.dart @@ -49,6 +49,8 @@ class _NoteListScreenState extends State { Timer? _debounce; int? _highlighted; // Note picked with the arrow keys, opened by Enter. final _highlightedKey = GlobalKey(); + final _listFocus = FocusNode(); + final _menuKey = GlobalKey>(); @override void initState() { @@ -73,6 +75,7 @@ class _NoteListScreenState extends State { 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( + 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( @@ -872,10 +924,6 @@ Happy note taking! ✨ title: Text( _includeFileContent ? 'Exclude File Content' : 'Search File Content', ), - onTap: () { - _toggleIncludeFileContent(!_includeFileContent); - Navigator.pop(context); - }, ), ), PopupMenuItem( @@ -932,7 +980,20 @@ Happy note taking! ✨ ), body: Padding( padding: EdgeInsets.only(left: 10), - child: NotificationListener( + 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( 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! ✨ }, ), ), + ), + ), ), ), ); diff --git a/lib/shortcuts.dart b/lib/shortcuts.dart index 54451e4..acb5682 100644 --- a/lib/shortcuts.dart +++ b/lib/shortcuts.dart @@ -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> 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 quitApp() => diff --git a/pubspec.yaml b/pubspec.yaml index b891c66..39e2887 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/note_editor_shortcuts_test.dart b/test/note_editor_shortcuts_test.dart index da8a147..12d5a48 100644 --- a/test/note_editor_shortcuts_test.dart +++ b/test/note_editor_shortcuts_test.dart @@ -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.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.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() != null || + context.widget is Slider) { + return 'slider'; + } + final texts = find.descendant( + of: find.byWidget(context.widget), matching: find.byType(Text)); + return tester.widgetList(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(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 { diff --git a/test/note_list_dpad_test.dart b/test/note_list_dpad_test.dart new file mode 100644 index 0000000..ac48250 --- /dev/null +++ b/test/note_list_dpad_test.dart @@ -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 getApplicationDocumentsPath() async => path; +} + +Future _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 pumpList(WidgetTester tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(MaterialApp( + home: NoteListScreen( + toggleTheme: (_) {}, + toggleTags: () {}, + isDarkMode: false, + showTags: true, + ), + )); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pumpAndSettle(); + } + + bool searchHasFocus(WidgetTester tester) => + tester.widget(find.byType(TextField)).focusNode!.hasFocus; + + List highlighted(WidgetTester tester) => [ + for (final tile in tester.widgetList(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.delayed(const Duration(milliseconds: 20)); + } + }); + expect(find.byType(NoteEditorScreen), findsOneWidget); + final editor = tester.widget(find.descendant( + of: find.byType(NoteEditorScreen), matching: find.byType(TextField))); + expect(editor.controller!.text, matches(r'^# (alpha|beta|gamma)\n$')); + }); +}