fix(tv): let text fields fall back to directional traversal
close #1176
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -171,7 +171,6 @@ class _PlexMatchScreenState extends State<PlexMatchScreen> 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<PlexMatchScreen> with ControllerDispos
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _search(),
|
||||
onNavigateLeft: _nameFocus.requestFocus,
|
||||
onNavigateDown: _searchFocus.requestFocus,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -115,8 +115,6 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> 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<AddLocalProfileScreen> 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<AddLocalProfileScreen> 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),
|
||||
|
||||
@@ -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<JellyfinConnectionAuthService> Function()? _authServiceFactory;
|
||||
|
||||
const AddJellyfinScreen({super.key, this.targetProfile});
|
||||
const AddJellyfinScreen({
|
||||
super.key,
|
||||
this.targetProfile,
|
||||
@visibleForTesting FutureOr<JellyfinConnectionAuthService> Function()? authServiceFactory,
|
||||
}) : _authServiceFactory = authServiceFactory;
|
||||
|
||||
@override
|
||||
State<AddJellyfinScreen> createState() => _AddJellyfinScreenState();
|
||||
@@ -317,6 +322,8 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
}
|
||||
|
||||
Future<JellyfinConnectionAuthService> _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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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),
|
||||
|
||||
@@ -115,7 +115,6 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
focusNode: _saveFocus,
|
||||
useBackgroundFocus: true,
|
||||
onPressed: busy ? null : _save,
|
||||
onNavigateUp: () => _urlsFocus.requestFocus(),
|
||||
child: FilledButton.icon(
|
||||
onPressed: busy ? null : _save,
|
||||
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.save_rounded, fill: 1),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -94,8 +94,6 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onNavigateUp: _closeFocusNode.requestFocus,
|
||||
onNavigateDown: _joinFocusNode.requestFocus,
|
||||
onFieldSubmitted: (_) => _join(),
|
||||
autofocus: true,
|
||||
),
|
||||
|
||||
@@ -330,7 +330,6 @@ class _DiscoveryViewState extends State<DiscoveryView> 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<DiscoveryView> 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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user