diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 7c4c7ad0..6a4c6d4c 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -74,6 +74,7 @@ class _NativeTvTextInputFocusBridge { KeyEventResult _handleInputKey({ required TextEditingController controller, + required FocusNode node, required bool usesTvKeyboard, required bool enabled, required VoidCallback openKeyboard, @@ -150,24 +151,52 @@ KeyEventResult _handleInputKey({ if (!event.isActionable) return finish(KeyEventResult.ignored, 'non-actionable'); - if (key.isUpKey && onNavigateUp != null) { - onNavigateUp(); - return finish(KeyEventResult.handled, 'onNavigateUp'); + final isMultiline = _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines); + + // Directional escape: an explicit callback always wins. Otherwise, fall back + // to the framework's geometry-based directional traversal so an un-wired + // field moves to its nearest neighbour instead of dead-ending (the field's + // own onKeyEvent runs before EditableText, which would otherwise swallow the + // arrow). Single-line only for UP/DOWN — multiline falls through so + // EditableText can move the caret between lines. + if (key.isUpKey) { + if (onNavigateUp != null) { + onNavigateUp(); + return finish(KeyEventResult.handled, 'onNavigateUp'); + } + if (!isMultiline) { + final moved = node.focusInDirection(TraversalDirection.up); + return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-up'); + } } - if (key.isDownKey && onNavigateDown != null) { - onNavigateDown(); - return finish(KeyEventResult.handled, 'onNavigateDown'); + if (key.isDownKey) { + if (onNavigateDown != null) { + onNavigateDown(); + return finish(KeyEventResult.handled, 'onNavigateDown'); + } + if (!isMultiline) { + final moved = node.focusInDirection(TraversalDirection.down); + return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-down'); + } } final sel = controller.selection; if (sel.isCollapsed) { - if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) { - onNavigateLeft(); - return finish(KeyEventResult.handled, 'onNavigateLeft-at-start'); + if (key.isLeftKey && sel.baseOffset == 0) { + if (onNavigateLeft != null) { + onNavigateLeft(); + return finish(KeyEventResult.handled, 'onNavigateLeft-at-start'); + } + final moved = node.focusInDirection(TraversalDirection.left); + return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-left-at-start'); } - if (key.isRightKey && sel.baseOffset == controller.text.length && onNavigateRight != null) { - onNavigateRight(); - return finish(KeyEventResult.handled, 'onNavigateRight-at-end'); + if (key.isRightKey && sel.baseOffset == controller.text.length) { + if (onNavigateRight != null) { + onNavigateRight(); + return finish(KeyEventResult.handled, 'onNavigateRight-at-end'); + } + final moved = node.focusInDirection(TraversalDirection.right); + return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-right-at-end'); } } @@ -571,9 +600,10 @@ abstract class _FocusableTextInputBase extends StatelessWidget { } } - KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event, VoidCallback openKeyboard) { + KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) { return _handleInputKey( controller: controller, + node: node, usesTvKeyboard: _hasTvKeyboard, enabled: enabled, openKeyboard: openKeyboard, diff --git a/lib/screens/plex_match_screen.dart b/lib/screens/plex_match_screen.dart index e7f18aed..3ee07a61 100644 --- a/lib/screens/plex_match_screen.dart +++ b/lib/screens/plex_match_screen.dart @@ -171,7 +171,6 @@ class _PlexMatchScreenState extends State with ControllerDispos textInputAction: TextInputAction.next, onSubmitted: (_) => _yearFocus.requestFocus(), onNavigateRight: _yearFocus.requestFocus, - onNavigateDown: _searchFocus.requestFocus, ), ), const SizedBox(width: 12), @@ -186,7 +185,6 @@ class _PlexMatchScreenState extends State with ControllerDispos textInputAction: TextInputAction.search, onSubmitted: (_) => _search(), onNavigateLeft: _nameFocus.requestFocus, - onNavigateDown: _searchFocus.requestFocus, ), ), ], diff --git a/lib/screens/profile/add_local_profile_screen.dart b/lib/screens/profile/add_local_profile_screen.dart index bfeda97e..825342f6 100644 --- a/lib/screens/profile/add_local_profile_screen.dart +++ b/lib/screens/profile/add_local_profile_screen.dart @@ -115,8 +115,6 @@ class _AddLocalProfileScreenState extends State with Cont focusNode: _setPinFocus, useBackgroundFocus: true, onPressed: _setPin, - onNavigateUp: () => _nameFocus.requestFocus(), - onNavigateDown: () => _continueFocus.requestFocus(), child: OutlinedButton.icon( onPressed: _setPin, icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1), @@ -131,7 +129,6 @@ class _AddLocalProfileScreenState extends State with Cont useBackgroundFocus: true, onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue, onNavigateUp: () => (_pinHash == null ? _setPinFocus : _nameFocus).requestFocus(), - onNavigateDown: () => _cancelFocus.requestFocus(), child: FilledButton( onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue, child: Text(t.profiles.continueButton), @@ -142,7 +139,6 @@ class _AddLocalProfileScreenState extends State with Cont focusNode: _cancelFocus, useBackgroundFocus: true, onPressed: _saving ? null : () => Navigator.of(context).pop(), - onNavigateUp: () => _continueFocus.requestFocus(), child: TextButton( onPressed: _saving ? null : () => Navigator.of(context).pop(), child: Text(t.common.cancel), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 02668348..2746fd99 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -61,8 +61,13 @@ class AddJellyfinScreen extends StatefulWidget { /// [ProfileConnection] row. When null, falls back to the currently active /// profile (typical for the global Connections screen entry point). final Profile? targetProfile; + final FutureOr Function()? _authServiceFactory; - const AddJellyfinScreen({super.key, this.targetProfile}); + const AddJellyfinScreen({ + super.key, + this.targetProfile, + @visibleForTesting FutureOr Function()? authServiceFactory, + }) : _authServiceFactory = authServiceFactory; @override State createState() => _AddJellyfinScreenState(); @@ -317,6 +322,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } Future _buildAuthService() async { + final authServiceFactory = widget._authServiceFactory; + if (authServiceFactory != null) return await authServiceFactory(); final pkg = await PackageInfo.fromPlatform(); final deviceName = await _resolveDeviceName(); return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: pkg.version, deviceName: deviceName); @@ -379,7 +386,9 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _quickConnectEnabled = false; }); }, - onNavigateDown: _serverInfo == null ? () => _findServerFocus.requestFocus() : null, + onNavigateDown: _serverInfo == null + ? () => _findServerFocus.requestFocus() + : () => _usernameFocus.requestFocus(), textInputAction: TextInputAction.go, onFieldSubmitted: busy ? null : (_) => _probe(), decoration: InputDecoration( @@ -394,7 +403,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta focusNode: _findServerFocus, useBackgroundFocus: true, onPressed: busy ? null : _probe, - onNavigateUp: () => _urlFocus.requestFocus(), child: FilledButton.icon( onPressed: busy ? null : _probe, icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1), @@ -411,7 +419,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta autocorrect: false, enableSuggestions: false, enabled: !busy, - onNavigateDown: () => _passwordFocus.requestFocus(), + onNavigateUp: () => _urlFocus.requestFocus(), textInputAction: TextInputAction.next, onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(), decoration: InputDecoration( @@ -426,8 +434,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta focusNode: _passwordFocus, obscureText: true, enabled: !busy, - onNavigateUp: () => _usernameFocus.requestFocus(), - onNavigateDown: () => _signInFocus.requestFocus(), textInputAction: TextInputAction.done, onFieldSubmitted: busy ? null : (_) => _signIn(), decoration: InputDecoration( @@ -442,8 +448,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta focusNode: _signInFocus, useBackgroundFocus: true, onPressed: busy ? null : _signIn, - onNavigateUp: () => _passwordFocus.requestFocus(), - onNavigateDown: _quickConnectEnabled ? () => _quickConnectFocus.requestFocus() : null, child: FilledButton.icon( onPressed: busy ? null : _signIn, icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1), @@ -456,7 +460,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta focusNode: _quickConnectFocus, useBackgroundFocus: true, onPressed: busy ? null : _startQuickConnect, - onNavigateUp: () => _signInFocus.requestFocus(), child: OutlinedButton.icon( onPressed: busy ? null : _startQuickConnect, icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1), diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 3cde8223..cee0b9fc 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -115,7 +115,6 @@ class _EditJellyfinConnectionScreenState extends State _urlsFocus.requestFocus(), child: FilledButton.icon( onPressed: busy ? null : _save, icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.save_rounded, fill: 1), diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 43327ad9..79f02ab2 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -214,7 +214,6 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> DialogActionButton( focusNode: _cancelFocusNode, onPressed: () => Navigator.pop(context), - onNavigateUp: _fieldFocusNode.requestFocus, onNavigateRight: _saveFocusNode.requestFocus, label: t.common.cancel, ), @@ -222,7 +221,6 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog> onPressed: () => Navigator.pop(context, _controller.text), label: t.common.save, focusNode: _saveFocusNode, - onNavigateUp: _fieldFocusNode.requestFocus, onNavigateLeft: _cancelFocusNode.requestFocus, ), ], @@ -286,7 +284,6 @@ class _TextInputDialogState extends State<_TextInputDialog> DialogActionButton( focusNode: _cancelFocusNode, onPressed: () => Navigator.pop(context), - onNavigateUp: _fieldFocusNode.requestFocus, onNavigateRight: _saveFocusNode.requestFocus, label: t.common.cancel, ), @@ -294,7 +291,6 @@ class _TextInputDialogState extends State<_TextInputDialog> onPressed: _submit, label: widget.confirmText ?? t.common.save, focusNode: _saveFocusNode, - onNavigateUp: _fieldFocusNode.requestFocus, onNavigateLeft: _cancelFocusNode.requestFocus, ), ], diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 730a321d..4a77801c 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -324,14 +324,12 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta DialogActionButton( focusNode: cancelFocusNode, onPressed: () => Navigator.pop(context), - onNavigateUp: fieldFocusNode.requestFocus, onNavigateRight: saveFocusNode.requestFocus, label: t.common.cancel, ), DialogActionButton( focusNode: saveFocusNode, onPressed: () => Navigator.pop(context, controller.text), - onNavigateUp: fieldFocusNode.requestFocus, onNavigateLeft: cancelFocusNode.requestFocus, isPrimary: true, label: t.common.save, diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index 96b0edaa..a3194ed9 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -94,8 +94,6 @@ class _JoinSessionDialogState extends State with ControllerDi } return null; }, - onNavigateUp: _closeFocusNode.requestFocus, - onNavigateDown: _joinFocusNode.requestFocus, onFieldSubmitted: (_) => _join(), autofocus: true, ), diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 72fcba2a..cf5496ba 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -330,7 +330,6 @@ class _DiscoveryViewState extends State with ControllerDisposerMi disableScale: true, borderRadius: 8, onSelect: () => setState(() => _showManualEntry = !_showManualEntry), - onNavigateDown: _showManualEntry ? _hostAddressFocusNode.requestFocus : null, child: InkWell( canRequestFocus: false, borderRadius: const BorderRadius.all(Radius.circular(8)), @@ -382,13 +381,10 @@ class _DiscoveryViewState extends State with ControllerDisposerMi return null; }, enabled: !_isConnecting, - onNavigateUp: _manualToggleFocusNode.requestFocus, - onNavigateDown: _connectFocusNode.requestFocus, ), const SizedBox(height: 16), FocusableButton( focusNode: _connectFocusNode, - onNavigateUp: _hostAddressFocusNode.requestFocus, onPressed: _isConnecting ? null : _submitManualHost, child: FilledButton.icon( onPressed: _isConnecting ? null : _submitManualHost, diff --git a/test/focus/focusable_text_field_test.dart b/test/focus/focusable_text_field_test.dart new file mode 100644 index 00000000..0c99a559 --- /dev/null +++ b/test/focus/focusable_text_field_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_text_field.dart'; + +void main() { + testWidgets('unwired single-line fields traverse with arrow keys', (tester) async { + final first = FocusNode(debugLabel: 'first'); + final second = FocusNode(debugLabel: 'second'); + addTearDown(first.dispose); + addTearDown(second.dispose); + final c1 = TextEditingController(); + final c2 = TextEditingController(); + addTearDown(c1.dispose); + addTearDown(c2.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextField(controller: c1, focusNode: first, enableTvKeyboard: false), + FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false), + ], + ), + ), + ), + ); + await tester.pump(); + + first.requestFocus(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'first'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'second'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'first'); + }); + + testWidgets('unwired multiline field keeps arrow keys for the caret', (tester) async { + final first = FocusNode(debugLabel: 'multiline'); + final second = FocusNode(debugLabel: 'below'); + addTearDown(first.dispose); + addTearDown(second.dispose); + final c1 = TextEditingController(text: 'line1\nline2'); + final c2 = TextEditingController(); + addTearDown(c1.dispose); + addTearDown(c2.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextField(controller: c1, focusNode: first, maxLines: 4, enableTvKeyboard: false), + FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false), + ], + ), + ), + ), + ); + await tester.pump(); + + first.requestFocus(); + c1.selection = const TextSelection.collapsed(offset: 0); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'multiline'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'multiline'); + }); +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 3870225e..f14f748e 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -1,14 +1,40 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; +import 'package:plezy/services/jellyfin_auth_service.dart'; import 'package:plezy/utils/platform_detector.dart'; Profile _profile(String id) => Profile.local(id: id, displayName: id, sortOrder: 0, createdAt: DateTime.fromMillisecondsSinceEpoch(0)); +JellyfinConnectionAuthService _jellyfinAuthService({bool quickConnectEnabled = false}) { + return JellyfinConnectionAuthService( + clientName: 'Plezy', + clientVersion: 'test', + deviceName: 'TestDevice', + testHttpClientFactory: () => MockClient((request) async { + switch (request.url.path) { + case '/System/Info/Public': + return http.Response( + jsonEncode({'Id': 'srv-1', 'ServerName': 'Home', 'Version': '10.9.0'}), + 200, + headers: {'content-type': 'application/json'}, + ); + case '/QuickConnect/Enabled': + return http.Response(jsonEncode(quickConnectEnabled), 200, headers: {'content-type': 'application/json'}); + } + return http.Response('', 404); + }), + ); +} + void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); @@ -56,6 +82,30 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); }); + testWidgets('D-pad moves from URL to credentials after server is found', (tester) async { + await tester.pumpWidget(MaterialApp(home: AddJellyfinScreen(authServiceFactory: () => _jellyfinAuthService()))); + await tester.pump(); + + await tester.enterText(find.byType(TextField).first, 'https://jf.example.com'); + await tester.testTextInput.receiveAction(TextInputAction.go); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TextField).first); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Username'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + }); + group('Jellyfin profile binding decisions', () { test('creates a local profile only on true first-run with no profiles', () { expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue);