fix(tv): let text fields fall back to directional traversal

close #1176
This commit is contained in:
edde746
2026-05-29 03:30:54 +02:00
parent 1cd585eecb
commit a931d5910b
11 changed files with 182 additions and 41 deletions
+43 -13
View File
@@ -74,6 +74,7 @@ class _NativeTvTextInputFocusBridge {
KeyEventResult _handleInputKey({ KeyEventResult _handleInputKey({
required TextEditingController controller, required TextEditingController controller,
required FocusNode node,
required bool usesTvKeyboard, required bool usesTvKeyboard,
required bool enabled, required bool enabled,
required VoidCallback openKeyboard, required VoidCallback openKeyboard,
@@ -150,24 +151,52 @@ KeyEventResult _handleInputKey({
if (!event.isActionable) return finish(KeyEventResult.ignored, 'non-actionable'); if (!event.isActionable) return finish(KeyEventResult.ignored, 'non-actionable');
if (key.isUpKey && onNavigateUp != null) { final isMultiline = _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines);
onNavigateUp();
return finish(KeyEventResult.handled, 'onNavigateUp'); // 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) { if (key.isDownKey) {
onNavigateDown(); if (onNavigateDown != null) {
return finish(KeyEventResult.handled, 'onNavigateDown'); 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; final sel = controller.selection;
if (sel.isCollapsed) { if (sel.isCollapsed) {
if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) { if (key.isLeftKey && sel.baseOffset == 0) {
onNavigateLeft(); if (onNavigateLeft != null) {
return finish(KeyEventResult.handled, 'onNavigateLeft-at-start'); 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) { if (key.isRightKey && sel.baseOffset == controller.text.length) {
onNavigateRight(); if (onNavigateRight != null) {
return finish(KeyEventResult.handled, 'onNavigateRight-at-end'); 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( return _handleInputKey(
controller: controller, controller: controller,
node: node,
usesTvKeyboard: _hasTvKeyboard, usesTvKeyboard: _hasTvKeyboard,
enabled: enabled, enabled: enabled,
openKeyboard: openKeyboard, openKeyboard: openKeyboard,
-2
View File
@@ -171,7 +171,6 @@ class _PlexMatchScreenState extends State<PlexMatchScreen> with ControllerDispos
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onSubmitted: (_) => _yearFocus.requestFocus(), onSubmitted: (_) => _yearFocus.requestFocus(),
onNavigateRight: _yearFocus.requestFocus, onNavigateRight: _yearFocus.requestFocus,
onNavigateDown: _searchFocus.requestFocus,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -186,7 +185,6 @@ class _PlexMatchScreenState extends State<PlexMatchScreen> with ControllerDispos
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onSubmitted: (_) => _search(), onSubmitted: (_) => _search(),
onNavigateLeft: _nameFocus.requestFocus, onNavigateLeft: _nameFocus.requestFocus,
onNavigateDown: _searchFocus.requestFocus,
), ),
), ),
], ],
@@ -115,8 +115,6 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with Cont
focusNode: _setPinFocus, focusNode: _setPinFocus,
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: _setPin, onPressed: _setPin,
onNavigateUp: () => _nameFocus.requestFocus(),
onNavigateDown: () => _continueFocus.requestFocus(),
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _setPin, onPressed: _setPin,
icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1), icon: const AppIcon(Symbols.lock_outline_rounded, fill: 1),
@@ -131,7 +129,6 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with Cont
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue, onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
onNavigateUp: () => (_pinHash == null ? _setPinFocus : _nameFocus).requestFocus(), onNavigateUp: () => (_pinHash == null ? _setPinFocus : _nameFocus).requestFocus(),
onNavigateDown: () => _cancelFocus.requestFocus(),
child: FilledButton( child: FilledButton(
onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue, onPressed: _saving || _nameController.text.trim().isEmpty ? null : _saveAndContinue,
child: Text(t.profiles.continueButton), child: Text(t.profiles.continueButton),
@@ -142,7 +139,6 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with Cont
focusNode: _cancelFocus, focusNode: _cancelFocus,
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: _saving ? null : () => Navigator.of(context).pop(), onPressed: _saving ? null : () => Navigator.of(context).pop(),
onNavigateUp: () => _continueFocus.requestFocus(),
child: TextButton( child: TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(), onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: Text(t.common.cancel), child: Text(t.common.cancel),
+12 -9
View File
@@ -61,8 +61,13 @@ class AddJellyfinScreen extends StatefulWidget {
/// [ProfileConnection] row. When null, falls back to the currently active /// [ProfileConnection] row. When null, falls back to the currently active
/// profile (typical for the global Connections screen entry point). /// profile (typical for the global Connections screen entry point).
final Profile? targetProfile; 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 @override
State<AddJellyfinScreen> createState() => _AddJellyfinScreenState(); State<AddJellyfinScreen> createState() => _AddJellyfinScreenState();
@@ -317,6 +322,8 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
} }
Future<JellyfinConnectionAuthService> _buildAuthService() async { Future<JellyfinConnectionAuthService> _buildAuthService() async {
final authServiceFactory = widget._authServiceFactory;
if (authServiceFactory != null) return await authServiceFactory();
final pkg = await PackageInfo.fromPlatform(); final pkg = await PackageInfo.fromPlatform();
final deviceName = await _resolveDeviceName(); final deviceName = await _resolveDeviceName();
return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: pkg.version, deviceName: deviceName); return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: pkg.version, deviceName: deviceName);
@@ -379,7 +386,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
_quickConnectEnabled = false; _quickConnectEnabled = false;
}); });
}, },
onNavigateDown: _serverInfo == null ? () => _findServerFocus.requestFocus() : null, onNavigateDown: _serverInfo == null
? () => _findServerFocus.requestFocus()
: () => _usernameFocus.requestFocus(),
textInputAction: TextInputAction.go, textInputAction: TextInputAction.go,
onFieldSubmitted: busy ? null : (_) => _probe(), onFieldSubmitted: busy ? null : (_) => _probe(),
decoration: InputDecoration( decoration: InputDecoration(
@@ -394,7 +403,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
focusNode: _findServerFocus, focusNode: _findServerFocus,
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: busy ? null : _probe, onPressed: busy ? null : _probe,
onNavigateUp: () => _urlFocus.requestFocus(),
child: FilledButton.icon( child: FilledButton.icon(
onPressed: busy ? null : _probe, onPressed: busy ? null : _probe,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1), 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, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
enabled: !busy, enabled: !busy,
onNavigateDown: () => _passwordFocus.requestFocus(), onNavigateUp: () => _urlFocus.requestFocus(),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(), onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(),
decoration: InputDecoration( decoration: InputDecoration(
@@ -426,8 +434,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
focusNode: _passwordFocus, focusNode: _passwordFocus,
obscureText: true, obscureText: true,
enabled: !busy, enabled: !busy,
onNavigateUp: () => _usernameFocus.requestFocus(),
onNavigateDown: () => _signInFocus.requestFocus(),
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: busy ? null : (_) => _signIn(), onFieldSubmitted: busy ? null : (_) => _signIn(),
decoration: InputDecoration( decoration: InputDecoration(
@@ -442,8 +448,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
focusNode: _signInFocus, focusNode: _signInFocus,
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: busy ? null : _signIn, onPressed: busy ? null : _signIn,
onNavigateUp: () => _passwordFocus.requestFocus(),
onNavigateDown: _quickConnectEnabled ? () => _quickConnectFocus.requestFocus() : null,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: busy ? null : _signIn, onPressed: busy ? null : _signIn,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1), icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
@@ -456,7 +460,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
focusNode: _quickConnectFocus, focusNode: _quickConnectFocus,
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: busy ? null : _startQuickConnect, onPressed: busy ? null : _startQuickConnect,
onNavigateUp: () => _signInFocus.requestFocus(),
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: busy ? null : _startQuickConnect, onPressed: busy ? null : _startQuickConnect,
icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1), icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1),
@@ -115,7 +115,6 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
focusNode: _saveFocus, focusNode: _saveFocus,
useBackgroundFocus: true, useBackgroundFocus: true,
onPressed: busy ? null : _save, onPressed: busy ? null : _save,
onNavigateUp: () => _urlsFocus.requestFocus(),
child: FilledButton.icon( child: FilledButton.icon(
onPressed: busy ? null : _save, onPressed: busy ? null : _save,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.save_rounded, fill: 1), icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.save_rounded, fill: 1),
-4
View File
@@ -214,7 +214,6 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
DialogActionButton( DialogActionButton(
focusNode: _cancelFocusNode, focusNode: _cancelFocusNode,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateRight: _saveFocusNode.requestFocus, onNavigateRight: _saveFocusNode.requestFocus,
label: t.common.cancel, label: t.common.cancel,
), ),
@@ -222,7 +221,6 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
onPressed: () => Navigator.pop(context, _controller.text), onPressed: () => Navigator.pop(context, _controller.text),
label: t.common.save, label: t.common.save,
focusNode: _saveFocusNode, focusNode: _saveFocusNode,
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateLeft: _cancelFocusNode.requestFocus, onNavigateLeft: _cancelFocusNode.requestFocus,
), ),
], ],
@@ -286,7 +284,6 @@ class _TextInputDialogState extends State<_TextInputDialog>
DialogActionButton( DialogActionButton(
focusNode: _cancelFocusNode, focusNode: _cancelFocusNode,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateRight: _saveFocusNode.requestFocus, onNavigateRight: _saveFocusNode.requestFocus,
label: t.common.cancel, label: t.common.cancel,
), ),
@@ -294,7 +291,6 @@ class _TextInputDialogState extends State<_TextInputDialog>
onPressed: _submit, onPressed: _submit,
label: widget.confirmText ?? t.common.save, label: widget.confirmText ?? t.common.save,
focusNode: _saveFocusNode, focusNode: _saveFocusNode,
onNavigateUp: _fieldFocusNode.requestFocus,
onNavigateLeft: _cancelFocusNode.requestFocus, onNavigateLeft: _cancelFocusNode.requestFocus,
), ),
], ],
@@ -324,14 +324,12 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta
DialogActionButton( DialogActionButton(
focusNode: cancelFocusNode, focusNode: cancelFocusNode,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
onNavigateUp: fieldFocusNode.requestFocus,
onNavigateRight: saveFocusNode.requestFocus, onNavigateRight: saveFocusNode.requestFocus,
label: t.common.cancel, label: t.common.cancel,
), ),
DialogActionButton( DialogActionButton(
focusNode: saveFocusNode, focusNode: saveFocusNode,
onPressed: () => Navigator.pop(context, controller.text), onPressed: () => Navigator.pop(context, controller.text),
onNavigateUp: fieldFocusNode.requestFocus,
onNavigateLeft: cancelFocusNode.requestFocus, onNavigateLeft: cancelFocusNode.requestFocus,
isPrimary: true, isPrimary: true,
label: t.common.save, label: t.common.save,
@@ -94,8 +94,6 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
} }
return null; return null;
}, },
onNavigateUp: _closeFocusNode.requestFocus,
onNavigateDown: _joinFocusNode.requestFocus,
onFieldSubmitted: (_) => _join(), onFieldSubmitted: (_) => _join(),
autofocus: true, autofocus: true,
), ),
@@ -330,7 +330,6 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
disableScale: true, disableScale: true,
borderRadius: 8, borderRadius: 8,
onSelect: () => setState(() => _showManualEntry = !_showManualEntry), onSelect: () => setState(() => _showManualEntry = !_showManualEntry),
onNavigateDown: _showManualEntry ? _hostAddressFocusNode.requestFocus : null,
child: InkWell( child: InkWell(
canRequestFocus: false, canRequestFocus: false,
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -382,13 +381,10 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
return null; return null;
}, },
enabled: !_isConnecting, enabled: !_isConnecting,
onNavigateUp: _manualToggleFocusNode.requestFocus,
onNavigateDown: _connectFocusNode.requestFocus,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FocusableButton( FocusableButton(
focusNode: _connectFocusNode, focusNode: _connectFocusNode,
onNavigateUp: _hostAddressFocusNode.requestFocus,
onPressed: _isConnecting ? null : _submitManualHost, onPressed: _isConnecting ? null : _submitManualHost,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _isConnecting ? null : _submitManualHost, onPressed: _isConnecting ? null : _submitManualHost,
+77
View File
@@ -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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.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/focus/input_mode_tracker.dart';
import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile.dart';
import 'package:plezy/screens/settings/add_jellyfin_screen.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'; import 'package:plezy/utils/platform_detector.dart';
Profile _profile(String id) => Profile _profile(String id) =>
Profile.local(id: id, displayName: id, sortOrder: 0, createdAt: DateTime.fromMillisecondsSinceEpoch(0)); 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() { void main() {
tearDown(() { tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(null);
@@ -56,6 +82,30 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); 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', () { group('Jellyfin profile binding decisions', () {
test('creates a local profile only on true first-run with no profiles', () { test('creates a local profile only on true first-run with no profiles', () {
expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue); expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue);