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({
|
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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user