feat(ui): polish Android TV remote focus and typing

Changelog: Clearer focus and typing on Android TV
This commit is contained in:
chen08209
2026-10-16 11:51:39 +08:00
parent 7fe3f21aa4
commit 67957e74a4
21 changed files with 1938 additions and 309 deletions
@@ -20,6 +20,9 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
@@ -103,6 +106,22 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private var skipNotificationPermissionRequest = false
@Suppress("DEPRECATION")
private val isTelevision by lazy {
val packageManager = GlobalState.application.packageManager
packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
private var floatingIme = false
// A TV keyboard floats over the app and reports a zero-height IME inset.
private val imeInsetsListener = OnApplyWindowInsetsListener { view, insets ->
val ime = WindowInsetsCompat.Type.ime()
reportFloatingIme(insets.isVisible(ime) && insets.getInsets(ime).bottom == 0)
ViewCompat.onApplyWindowInsets(view, insets)
}
private val mainHandler = Handler(Looper.getMainLooper())
/**
@@ -431,9 +450,21 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
activity = binding.activity
binding.addActivityResultListener(activityResultListener)
binding.addRequestPermissionsResultListener(permissionsResultListener)
if (isTelevision) {
ViewCompat.setOnApplyWindowInsetsListener(
binding.activity.window.decorView,
imeInsetsListener,
)
}
}
private fun detachFromActivity() {
if (isTelevision) {
activity?.window?.decorView?.let {
ViewCompat.setOnApplyWindowInsetsListener(it, null)
}
reportFloatingIme(false)
}
activity = null
val binding = activityBinding ?: return
activityBinding = null
@@ -441,6 +472,14 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
binding.removeRequestPermissionsResultListener(permissionsResultListener)
}
private fun reportFloatingIme(floating: Boolean) {
if (floating == floatingIme) {
return
}
floatingIme = floating
channel.invokeMethod("floatingImeChanged", floating)
}
override fun onDetachedFromActivityForConfigChanges() {
detachFromActivity()
}
+5 -1
View File
@@ -160,7 +160,11 @@ class ApplicationState extends ConsumerState<Application> {
isDesktop: system.isDesktop,
onConnectivityChanged: _handleConnectivityChanged,
terminateSignals: _terminateSignals,
child: RemoteFocusAdapter(enabled: system.isTV, child: child!),
child: RemoteFocusAdapter(
enabled: system.isTV,
floatingKeyboard: app?.floatingKeyboard,
child: child!,
),
);
},
scrollBehavior: const BaseScrollBehavior(),
+26 -21
View File
@@ -278,13 +278,16 @@ class _EditorPageState extends ConsumerState<EditorPage> {
tooltip: appLocalizations.search,
onPressed: isReady ? _handleSearch : null,
),
if (widget.onSave != null)
IconButtonData(
glyph: AppGlyphs.check,
tooltip: appLocalizations.save,
onPressed: _barState.isDirty && !_saving ? _handleSave : null,
),
],
confirmationAction: widget.onSave == null
? null
: IconButtonData(
glyph: AppGlyphs.check,
tooltip: appLocalizations.save,
onPressed: _barState.isDirty && !_saving
? _handleSave
: null,
),
menuItems: [
CommonPopupMenuItem(
glyph: AppGlyphs.undo,
@@ -1208,20 +1211,22 @@ class _FindOptionToggle extends StatelessWidget {
tooltip: tooltip,
isSelected: selected,
onPressed: onPressed,
style: ElasticPress.buttonStyle.merge(
IconButton.styleFrom(
backgroundColor: selected ? colorScheme.primary : null,
foregroundColor: selected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fixedSize: Size.square(TonalButtonSize.compact.button),
minimumSize: Size.square(TonalButtonSize.compact.button),
padding: EdgeInsets.zero,
iconSize: TonalButtonSize.compact.icon,
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
style: ElasticPress.buttonStyle
.merge(
IconButton.styleFrom(
backgroundColor: selected ? colorScheme.primary : null,
foregroundColor: selected
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fixedSize: Size.square(TonalButtonSize.compact.button),
minimumSize: Size.square(TonalButtonSize.compact.button),
padding: EdgeInsets.zero,
iconSize: TonalButtonSize.compact.icon,
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
)
.merge(focusRingStyle(colorScheme, standsOff: selected)),
icon: AnimatedGlyph(glyph: glyph, filled: selected),
),
);
@@ -1255,7 +1260,7 @@ class _FindBarButton extends StatelessWidget {
? IconButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
)
).merge(focusRingStyle(colorScheme, standsOff: true))
: null,
icon: GlyphIcon(glyph),
),
+1 -1
View File
@@ -598,7 +598,7 @@ class _TorchButton extends StatelessWidget {
iconSize: _torchIconSize,
backgroundColor: lit && !glass ? colorScheme.primary : null,
foregroundColor: lit ? colorScheme.onPrimary : null,
),
).merge(focusRingStyle(colorScheme, standsOff: lit && !glass)),
onPressed: controller.toggleTorch,
icon: const GlyphIcon(AppGlyphs.torchOff),
selectedIcon: GlyphIcon(
+3
View File
@@ -15,6 +15,7 @@ class App {
late MethodChannel methodChannel;
Function()? onExit;
Function()? onPackagesChanged;
final floatingKeyboard = ValueNotifier(false);
App._internal() {
methodChannel = const MethodChannel('$packageName/app');
@@ -26,6 +27,8 @@ class App {
}
case 'packagesChanged':
onPackagesChanged?.call();
case 'floatingImeChanged':
floatingKeyboard.value = call.arguments as bool;
default:
throw MissingPluginException();
}
+13 -4
View File
@@ -214,21 +214,30 @@ class TonalButtonTheme extends StatelessWidget {
);
}
ButtonStyle feedback(Color? tint, ButtonStyle? pane) => ButtonStyle(
ButtonStyle feedback(
Color? tint,
ButtonStyle? pane, {
required bool standsOff,
}) => ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty<Color?>.fromMap({
if (tint != null) WidgetState.focused: tint.withValues(alpha: 0.1),
WidgetState.pressed: Colors.transparent,
if (tint != null) WidgetState.hovered: tint.withValues(alpha: 0.08),
}),
).merge(pane);
).merge(pane).merge(focusRingStyle(colorScheme, standsOff: standsOff));
final iconPane = paneOf((states) => states.contains(WidgetState.selected));
final filledPane = paneOf(
(states) => !states.contains(WidgetState.disabled),
);
final filledFeedback = feedback(
grouped ? foreground : null,
filledPane,
standsOff: !grouped,
);
final themed = IconButtonTheme(
data: IconButtonThemeData(
style: feedback(foreground, iconPane).merge(
style: feedback(foreground, iconPane, standsOff: paned).merge(
IconButton.styleFrom(
backgroundColor: fill,
foregroundColor: foreground,
@@ -245,7 +254,7 @@ class TonalButtonTheme extends StatelessWidget {
),
child: FilledButtonTheme(
data: FilledButtonThemeData(
style: feedback(grouped ? foreground : null, filledPane).merge(
style: filledFeedback.merge(
FilledButton.styleFrom(
backgroundColor: grouped ? fill : null,
foregroundColor: grouped
+52 -31
View File
@@ -1,4 +1,7 @@
import 'dart:math' as math;
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/focus.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/measure.dart';
import 'package:fl_clash/ui/metrics.dart';
@@ -34,10 +37,17 @@ class InfoHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
EdgeInsetsGeometry nextPadding = (padding ?? baseInfoEdgeInsets);
final actionInset = 8.mAp(context);
EdgeInsetsGeometry nextPadding = (padding ?? baseInfoEdgeInsetsOf(context));
if (actions.isNotEmpty) {
nextPadding = nextPadding.subtract(EdgeInsets.symmetric(vertical: 8.mAp));
nextPadding = nextPadding.subtract(
EdgeInsets.symmetric(vertical: actionInset),
);
}
final lineHeight = MeasureScope.of(context).cardHeaderHeight;
final rowHeight = info.glyph == null
? lineHeight
: math.max(lineHeight, IconTheme.of(context).size ?? Glyph.size);
return Padding(
padding: nextPadding,
child: Row(
@@ -54,7 +64,7 @@ class InfoHeader extends StatelessWidget {
glyph,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
const SizedBox(width: 12),
],
Flexible(
flex: 1,
@@ -73,7 +83,7 @@ class InfoHeader extends StatelessWidget {
const SizedBox(width: 8),
if (actions.isNotEmpty)
SizedBox(
height: MeasureScope.of(context).cardHeaderHeight + 16.ap,
height: rowHeight + actionInset * 2,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
@@ -122,15 +132,17 @@ class CommonCard extends StatelessWidget {
final OutlinedBorder? shape;
final WidgetStatesController? statesController;
BorderSide _buildBorderSide(BuildContext context, Set<WidgetState> states) {
if (!states.contains(WidgetState.focused)) {
BorderSide _buildBorderSide(
BuildContext context,
Set<WidgetState> states, {
required bool ringed,
}) {
if (!ringed || !states.contains(WidgetState.focused)) {
return BorderSide.none;
}
final colorScheme = context.colorScheme;
return BorderSide(
color: isError ? colorScheme.error : colorScheme.primary,
width: 2,
);
final side = focusRingSide(colorScheme);
return isError ? side.copyWith(color: colorScheme.error) : side;
}
bool get _filled => isSelected && fillsSelection;
@@ -171,28 +183,37 @@ class CommonCard extends StatelessWidget {
Widget _buildButton(BuildContext context, SurfaceLevel level) {
final iconColor = _buildIconColor(context);
final foregroundColor = _buildForegroundColor(context);
return FilledButton(
statesController: statesController,
clipBehavior: Clip.antiAlias,
style:
FilledButton.styleFrom(
padding: padding ?? EdgeInsets.zero,
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
iconSize: commonCardIconSize,
iconColor: iconColor,
foregroundColor: foregroundColor,
disabledIconColor: inert ? iconColor : null,
disabledForegroundColor: inert ? foregroundColor : null,
elevation: 0,
).copyWith(
backgroundColor: WidgetStatePropertyAll(
_buildBackgroundColor(context, level),
final remote = RemoteFocusAdapter.isEnabledOf(context);
return ValueListenableBuilder(
valueListenable: FocusMovedByKey.instance,
builder: (context, movedByKey, content) => FilledButton(
statesController: statesController,
clipBehavior: Clip.antiAlias,
style:
FilledButton.styleFrom(
padding: padding ?? EdgeInsets.zero,
shape: shape ?? AppShape.all(radius ?? AppCorner.md),
iconSize: commonCardIconSize,
iconColor: iconColor,
foregroundColor: foregroundColor,
disabledIconColor: inert ? iconColor : null,
disabledForegroundColor: inert ? foregroundColor : null,
elevation: 0,
).copyWith(
backgroundColor: WidgetStatePropertyAll(
_buildBackgroundColor(context, level),
),
side: WidgetStateProperty.resolveWith(
(states) => _buildBorderSide(
context,
states,
ringed: remote || movedByKey,
),
),
),
side: WidgetStateProperty.resolveWith(
(states) => _buildBorderSide(context, states),
),
),
onPressed: inert ? null : onPressed,
onPressed: inert ? null : onPressed,
child: content,
),
child: Semantics(
selected: isSelected ? true : null,
child: SurfaceScope(level: level, child: child),
+2 -3
View File
@@ -4,6 +4,7 @@ import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/item_position.dart';
import 'package:fl_clash/ui/shape.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:fl_clash/ui/typography.dart';
import 'package:material_ui/material_ui.dart';
@@ -118,9 +119,7 @@ class _SliverCollapsibleSectionState extends State<SliverCollapsibleSection>
child: DecoratedBox(
decoration: ShapeDecoration(
shape: AppShape.md.copyWith(
side: focused
? BorderSide(color: colorScheme.primary, width: 2)
: BorderSide.none,
side: focused ? focusRingSide(colorScheme) : BorderSide.none,
),
),
child: Padding(
+65 -4
View File
@@ -13,6 +13,11 @@ import 'package:material_ui/material_ui.dart';
const double _actionGap = 8;
const double _actionInset = 16;
const double _actionHeight = 48;
const double _textInset = 30;
const double _alertWidth = 348;
const double _contentMaxWidth = 300;
// Material 3 sets a filled field's text 4 in past its content padding.
const double _fieldInputGap = 4;
// UIAlertController on iOS 27 grows in from 1.2 times its size and only fades
// out, both on one critically damped spring that settles in 0.404 s.
@@ -45,7 +50,11 @@ Future<T?> showFloatingDialog<T>({
);
}
class _AlertRoute<T> extends PopupRoute<T> with AlertMotionRouteMixin<T> {
/// A route whose navigator an alert raised from inside it opens on.
mixin AlertHostRoute<T> on ModalRoute<T> {}
class _AlertRoute<T> extends PopupRoute<T>
with AlertMotionRouteMixin<T>, AlertHostRoute<T> {
_AlertRoute({
required this.barrierColor,
required this.barrierDismissible,
@@ -200,6 +209,9 @@ class CommonDialog extends StatelessWidget {
final List<Widget>? actions;
final EdgeInsets? padding;
/// Sets the fields in [child] as an iOS alert does: as wide as its buttons.
final bool hasFields;
const CommonDialog({
super.key,
required this.title,
@@ -207,11 +219,13 @@ class CommonDialog extends StatelessWidget {
this.actions,
this.child,
this.padding,
this.hasFields = false,
});
@override
Widget build(BuildContext context) {
final actions = this.actions ?? const <Widget>[];
final contentInset = hasFields ? _actionInset : _textInset;
return AlertDialog(
titleTextStyle: AppTextStyle.headline.of(context),
contentTextStyle: AppTextStyle.subheadline
@@ -232,16 +246,62 @@ class CommonDialog extends StatelessWidget {
_actionInset,
_actionInset,
),
contentPadding: padding,
titlePadding: const EdgeInsets.fromLTRB(_textInset, 24, _textInset, 0),
contentPadding:
padding ?? EdgeInsets.fromLTRB(contentInset, 16, contentInset, 24),
content: Container(
constraints: const BoxConstraints(maxWidth: 300),
constraints: BoxConstraints(
maxWidth: padding == null
? _alertWidth - contentInset * 2
: _contentMaxWidth,
),
width: double.infinity,
child: SingleChildScrollView(child: child),
child: SingleChildScrollView(
child: hasFields ? _AlertFieldTheme(child: child) : child,
),
),
);
}
}
class _AlertFieldTheme extends StatelessWidget {
const _AlertFieldTheme({required this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final shape = AppShape.input.copyWith(
borderRadius: AppRadius.all(_actionHeight / 2),
);
final none = shape.copyWith(borderSide: BorderSide.none);
return InputDecorationTheme(
data: InputDecorationTheme.of(context).copyWith(
filled: true,
fillColor: context.raisedSurfaceLevel.colorOf(colorScheme),
hoverColor: Colors.transparent,
floatingLabelBehavior: FloatingLabelBehavior.never,
contentPadding: const EdgeInsets.symmetric(
horizontal: _textInset - _actionInset - _fieldInputGap,
vertical: 12,
),
border: none,
enabledBorder: none,
disabledBorder: none,
errorBorder: none,
focusedBorder: shape.copyWith(
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
focusedErrorBorder: shape.copyWith(
borderSide: BorderSide(color: colorScheme.error, width: 2),
),
),
child: child ?? const SizedBox.shrink(),
);
}
}
/// Text buttons laid out and filled as [CommonDialog] sets its own.
class AlertActions extends StatelessWidget {
const AlertActions({super.key, required this.actions});
@@ -296,6 +356,7 @@ class _AlertAction extends StatelessWidget {
WidgetState.pressed: Colors.transparent,
WidgetState.hovered: foreground.withValues(alpha: 0.08),
}),
side: focusRingStyle(colorScheme, standsOff: isDefault).side,
),
),
child: ElasticPress(
+33 -208
View File
@@ -1,8 +1,19 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/metrics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
part 'focus_arrow_keys.dart';
part 'focus_keyboard.dart';
part 'focus_landing.dart';
part 'focus_moved_by_key.dart';
part 'focus_traversal.dart';
class PageFocusScope extends StatefulWidget {
final Widget child;
@@ -32,125 +43,6 @@ class _PageFocusScopeState extends State<PageFocusScope> {
}
}
/// Once a page has no target in a direction, the search continues among the
/// nodes outside the page (sidebar, navigation bar) from the focused control's
/// own rect, so left lands on the sidebar item beside it instead of wrapping.
class PageTraversalPolicy extends ReadingOrderTraversalPolicy {
@override
bool inDirection(FocusNode currentNode, TraversalDirection direction) {
final scope = currentNode.nearestScope;
if (super.inDirection(currentNode, direction)) {
return true;
}
if (scope == null) {
return false;
}
final found = _findOutside(scope, currentNode.rect, direction);
if (found == null) {
return false;
}
requestFocusCallback(
found,
alignmentPolicy: switch (direction) {
TraversalDirection.up || TraversalDirection.left =>
ScrollPositionAlignmentPolicy.keepVisibleAtStart,
TraversalDirection.down || TraversalDirection.right =>
ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
},
);
return true;
}
FocusNode? _findOutside(
FocusScopeNode scope,
Rect from,
TraversalDirection direction,
) {
final root = FocusManager.instance.rootScope;
for (
var parent = scope.enclosingScope;
parent != null && parent != root;
parent = parent.enclosingScope
) {
final level = parent;
final candidates = level.traversalDescendants.where(
(node) => node != scope && _isOutside(node, scope, level),
);
final found = _nearestInDirection(from, candidates, direction);
if (found != null) {
return found;
}
}
return null;
}
// A covered route's scope is marked skipped; its nodes stay in the tree.
static bool _isOutside(
FocusNode node,
FocusScopeNode scope,
FocusScopeNode level,
) {
for (final ancestor in node.ancestors) {
if (ancestor == level) {
return true;
}
if (ancestor == scope ||
ancestor is FocusScopeNode && ancestor.skipTraversal) {
return false;
}
}
return true;
}
static FocusNode? _nearestInDirection(
Rect from,
Iterable<FocusNode> nodes,
TraversalDirection direction,
) {
final vertical =
direction == TraversalDirection.up ||
direction == TraversalDirection.down;
bool ahead(Rect rect) => switch (direction) {
TraversalDirection.up => rect.center.dy <= from.top,
TraversalDirection.down => rect.center.dy >= from.bottom,
TraversalDirection.left => rect.center.dx <= from.left,
TraversalDirection.right => rect.center.dx >= from.right,
};
bool inBand(Rect rect) => vertical
? rect.right > from.left && rect.left < from.right
: rect.bottom > from.top && rect.top < from.bottom;
double along(Rect rect) => math.max(switch (direction) {
TraversalDirection.up => from.top - rect.bottom,
TraversalDirection.down => rect.top - from.bottom,
TraversalDirection.left => from.left - rect.right,
TraversalDirection.right => rect.left - from.right,
}, 0);
double across(Rect rect) => vertical
? (rect.center.dx - from.center.dx).abs()
: (rect.center.dy - from.center.dy).abs();
final aheadNodes = nodes.where((node) => ahead(node.rect)).toList();
final banded = aheadNodes.where((node) => inBand(node.rect)).toList();
if (banded.isNotEmpty) {
banded.sort((a, b) {
final primary = along(a.rect).compareTo(along(b.rect));
return primary != 0
? primary
: across(a.rect).compareTo(across(b.rect));
});
return banded.first;
}
if (vertical || aheadNodes.isEmpty) {
return null;
}
aheadNodes.sort((a, b) {
final primary = across(a.rect).compareTo(across(b.rect));
return primary != 0 ? primary : along(a.rect).compareTo(along(b.rect));
});
return aheadNodes.first;
}
}
final remoteSelectKeys = {
LogicalKeyboardKey.select,
LogicalKeyboardKey.enter,
@@ -158,16 +50,17 @@ final remoteSelectKeys = {
LogicalKeyboardKey.gameButtonA,
};
/// D-pad behaviour a remote-driven screen expects: a route that opens with
/// focus on its scope moves it to the first control, and arrows at the edge of
/// a text field leave the field instead of stalling the caret.
class RemoteFocusAdapter extends StatefulWidget {
final bool enabled;
/// Whether a keyboard is up that covers the screen without insetting it.
final ValueListenable<bool>? floatingKeyboard;
final Widget child;
const RemoteFocusAdapter({
super.key,
required this.enabled,
this.floatingKeyboard,
required this.child,
});
@@ -180,106 +73,38 @@ class RemoteFocusAdapter extends StatefulWidget {
}
class _RemoteFocusAdapterState extends State<RemoteFocusAdapter> {
final _policy = _RemoteTraversalPolicy();
@override
void initState() {
super.initState();
if (widget.enabled) {
FocusManager.instance.addListener(_handleFocusChange);
}
}
@override
void didUpdateWidget(RemoteFocusAdapter oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.enabled == widget.enabled) {
return;
}
if (widget.enabled) {
FocusManager.instance.addListener(_handleFocusChange);
} else {
FocusManager.instance.removeListener(_handleFocusChange);
}
FocusMovedByKey.instance._attach();
}
@override
void dispose() {
if (widget.enabled) {
FocusManager.instance.removeListener(_handleFocusChange);
}
FocusMovedByKey.instance._detach();
super.dispose();
}
void _handleFocusChange() {
final focus = FocusManager.instance.primaryFocus;
if (focus is FocusScopeNode && focus.focusedChild == null) {
focus.nextFocus();
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (!widget.enabled || event is KeyUpEvent) {
return KeyEventResult.ignored;
}
final direction = switch (event.logicalKey) {
LogicalKeyboardKey.arrowUp => TraversalDirection.up,
LogicalKeyboardKey.arrowDown => TraversalDirection.down,
LogicalKeyboardKey.arrowLeft => TraversalDirection.left,
LogicalKeyboardKey.arrowRight => TraversalDirection.right,
_ => null,
};
if (direction == null) {
return KeyEventResult.ignored;
}
final focus = FocusManager.instance.primaryFocus;
final editable = focus?.context
?.findAncestorStateOfType<EditableTextState>();
if (focus == null ||
editable == null ||
!_caretAtEdge(editable, direction)) {
return KeyEventResult.ignored;
}
return focus.focusInDirection(direction)
? KeyEventResult.handled
: KeyEventResult.ignored;
}
static bool _caretAtEdge(
EditableTextState editable,
TraversalDirection direction,
) {
final value = editable.textEditingValue;
final selection = value.selection;
if (!selection.isValid || !selection.isCollapsed) {
return false;
}
final length = value.text.length;
switch (direction) {
case TraversalDirection.left:
return selection.baseOffset == 0;
case TraversalDirection.right:
return selection.baseOffset == length;
case TraversalDirection.up:
case TraversalDirection.down:
final render = editable.renderEditable;
final caret = render.getLocalRectForCaret(selection.extent);
final edge = render.getLocalRectForCaret(
TextPosition(offset: direction == TraversalDirection.up ? 0 : length),
);
return (caret.top - edge.top).abs() < 1;
}
}
@override
Widget build(BuildContext context) {
return _RemoteScope(
enabled: widget.enabled,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
onKeyEvent: _handleKeyEvent,
child: widget.child,
),
child: widget.enabled
? _RemoteFocusLanding(
child: _RemoteArrowKeys(
// Keys bubble outward; the keyboard must hear those arrows take.
child: _RemoteKeyboard(
floatingKeyboard: widget.floatingKeyboard,
child: FocusTraversalGroup(
policy: _policy,
child: widget.child,
),
),
),
)
: widget.child,
);
}
}
+170
View File
@@ -0,0 +1,170 @@
part of 'focus.dart';
/// Arrows at the edge of a text field leave the field instead of stalling the
/// caret, and up or down with no control that way scrolls the text there into
/// reach.
class _RemoteArrowKeys extends StatefulWidget {
const _RemoteArrowKeys({required this.child});
final Widget child;
@override
State<_RemoteArrowKeys> createState() => _RemoteArrowKeysState();
}
class _RemoteArrowKeysState extends State<_RemoteArrowKeys> {
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyUpEvent) {
return KeyEventResult.ignored;
}
final direction = switch (event.logicalKey) {
LogicalKeyboardKey.arrowUp => TraversalDirection.up,
LogicalKeyboardKey.arrowDown => TraversalDirection.down,
LogicalKeyboardKey.arrowLeft => TraversalDirection.left,
LogicalKeyboardKey.arrowRight => TraversalDirection.right,
_ => null,
};
if (direction == null) {
return KeyEventResult.ignored;
}
final focus = FocusManager.instance.primaryFocus;
if (focus == null) {
return KeyEventResult.ignored;
}
final editable = focus.context
?.findAncestorStateOfType<EditableTextState>();
if (editable == null) {
_scrollIfFocusStays(focus, direction);
return KeyEventResult.ignored;
}
if (!_caretAtEdge(editable, direction)) {
return KeyEventResult.ignored;
}
return focus.focusInDirection(direction)
? KeyEventResult.handled
: KeyEventResult.ignored;
}
void _scrollIfFocusStays(FocusNode focus, TraversalDirection direction) {
if (direction == TraversalDirection.left ||
direction == TraversalDirection.right) {
return;
}
// The traversal the key goes on to applies its focus change in a
// microtask, so only a later task can tell whether it found a target.
Timer.run(() {
final context = focus.context;
if (!mounted ||
context == null ||
FocusManager.instance.primaryFocus != focus) {
return;
}
final position = _scrollableToward(context, direction);
if (position == null) {
return;
}
final step = position.viewportDimension / 2;
final target =
(position.pixels +
(_scrollsForward(position, direction) ? step : -step))
.clamp(position.minScrollExtent, position.maxScrollExtent);
final duration = context.motionDuration(commonDuration);
if (duration == Duration.zero) {
position.jumpTo(target);
} else {
position.animateTo(
target,
duration: duration,
curve: Curves.easeOutCubic,
);
}
});
}
static bool _scrollsForward(
ScrollPosition position,
TraversalDirection direction,
) {
return (direction == TraversalDirection.down) !=
axisDirectionIsReversed(position.axisDirection);
}
static bool _canScroll(
ScrollPosition position,
TraversalDirection direction,
) {
if (!position.hasContentDimensions) {
return false;
}
return _scrollsForward(position, direction)
? position.pixels < position.maxScrollExtent
: position.pixels > position.minScrollExtent;
}
static ScrollPosition? _scrollableToward(
BuildContext context,
TraversalDirection direction,
) {
var scrollable = Scrollable.maybeOf(context, axis: Axis.vertical);
if (scrollable == null) {
return switch (PrimaryScrollController.maybeOf(context)) {
final controller?
when controller.positions.length == 1 &&
_canScroll(controller.position, direction) &&
_inSameRegion(context, controller.position) =>
controller.position,
_ => null,
};
}
while (scrollable != null) {
if (_canScroll(scrollable.position, direction)) {
return scrollable.position;
}
scrollable = Scrollable.maybeOf(scrollable.context, axis: Axis.vertical);
}
return null;
}
// The route's list beside the sidebar belongs to the page; a remote stopped at
// the sidebar's end is not pointing at it.
static bool _inSameRegion(BuildContext context, ScrollPosition position) =>
FocusTraversalGroup.maybeOf(context) ==
FocusTraversalGroup.maybeOf(position.context.storageContext);
static bool _caretAtEdge(
EditableTextState editable,
TraversalDirection direction,
) {
final value = editable.textEditingValue;
final selection = value.selection;
if (!selection.isValid || !selection.isCollapsed) {
return false;
}
final length = value.text.length;
switch (direction) {
case TraversalDirection.left:
return selection.baseOffset == 0;
case TraversalDirection.right:
return selection.baseOffset == length;
case TraversalDirection.up:
case TraversalDirection.down:
final render = editable.renderEditable;
final caret = render.getLocalRectForCaret(selection.extent);
final edge = render.getLocalRectForCaret(
TextPosition(offset: direction == TraversalDirection.up ? 0 : length),
);
return (caret.top - edge.top).abs() < 1;
}
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
onKeyEvent: _handleKeyEvent,
child: widget.child,
);
}
}
+171
View File
@@ -0,0 +1,171 @@
part of 'focus.dart';
// Android TV's keyboard covers about the lower 45% of the screen.
const _floatingKeyboardShare = 0.5;
const _maxRevealFrames = 30;
/// A text field the remote focuses opens the keyboard only on select.
class _RemoteKeyboard extends StatefulWidget {
const _RemoteKeyboard({required this.floatingKeyboard, required this.child});
final ValueListenable<bool>? floatingKeyboard;
final Widget child;
@override
State<_RemoteKeyboard> createState() => _RemoteKeyboardState();
}
class _RemoteKeyboardState extends State<_RemoteKeyboard> {
FocusNode? _outgoing;
bool _usingRemote = false;
bool _openedKeyboard = false;
@override
void initState() {
super.initState();
widget.floatingKeyboard?.addListener(_handleFloatingKeyboard);
FocusManager.instance.addListener(_handleFocusChange);
_watchOutgoing(FocusManager.instance.primaryFocus);
}
@override
void didUpdateWidget(_RemoteKeyboard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.floatingKeyboard != widget.floatingKeyboard) {
oldWidget.floatingKeyboard?.removeListener(_handleFloatingKeyboard);
widget.floatingKeyboard?.addListener(_handleFloatingKeyboard);
}
}
@override
void dispose() {
widget.floatingKeyboard?.removeListener(_handleFloatingKeyboard);
FocusManager.instance.removeListener(_handleFocusChange);
_watchOutgoing(null);
super.dispose();
}
void _handleFocusChange() {
final focus = FocusManager.instance.primaryFocus;
_watchOutgoing(focus);
if (_editableOf(focus) == null) {
_openedKeyboard = false;
}
}
void _watchOutgoing(FocusNode? node) {
_outgoing?.removeListener(_holdKeyboard);
_outgoing = node?..addListener(_holdKeyboard);
}
// The node losing primary focus hears of it before the field gaining it, whose
// own listener opens the keyboard on its token or on an invalid selection.
void _holdKeyboard() {
final focus = FocusManager.instance.primaryFocus;
if (focus == _outgoing || !_usingRemote || _keyboardShown) {
return;
}
final editable = _editableOf(focus);
if (focus == null || editable == null) {
return;
}
focus.consumeKeyboardToken();
final controller = editable.widget.controller;
if (!controller.selection.isValid) {
controller.selection = TextSelection.collapsed(
offset: controller.text.length,
);
}
}
bool get _keyboardFloating => widget.floatingKeyboard?.value ?? false;
bool get _keyboardShown =>
_openedKeyboard ||
_keyboardFloating ||
(View.maybeOf(context)?.viewInsets.bottom ?? 0) > 0;
void _handleFloatingKeyboard() {
setState(() {});
if (_keyboardFloating) {
_revealFocusedField();
}
}
// Containers ease into a new inset, so reveal the field until they settle.
void _revealFocusedField([int frame = 0]) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final editable = _editableOf(FocusManager.instance.primaryFocus);
if (!mounted || !_keyboardFloating || editable == null) {
return;
}
final box = editable.renderEditable;
box.showOnScreen(
rect: editable.widget.scrollPadding.inflateRect(Offset.zero & box.size),
);
if (frame < _maxRevealFrames &&
WidgetsBinding.instance.hasScheduledFrame) {
_revealFocusedField(frame + 1);
}
});
}
MediaQueryData _withFloatingKeyboard(MediaQueryData data) {
if (!_keyboardFloating || data.viewInsets.bottom > 0) {
return data;
}
return data.copyWith(
viewInsets: data.viewInsets.copyWith(
bottom: data.size.height * _floatingKeyboardShare,
),
);
}
static EditableTextState? _editableOf(FocusNode? focus) {
final editable = focus?.context
?.findAncestorStateOfType<EditableTextState>();
if (editable == null ||
editable.widget.focusNode != focus ||
editable.widget.readOnly) {
return null;
}
return editable;
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyUpEvent) {
return KeyEventResult.ignored;
}
_usingRemote = true;
// The keyboard takes the remote's keys while it is up, so one reaching the
// app means BACK has closed it.
_openedKeyboard = false;
if (!remoteSelectKeys.contains(event.logicalKey) || _keyboardShown) {
return KeyEventResult.ignored;
}
final editable = _editableOf(FocusManager.instance.primaryFocus);
if (editable == null) {
return KeyEventResult.ignored;
}
editable.requestKeyboard();
_openedKeyboard = true;
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (_) => _usingRemote = false,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
onKeyEvent: _handleKeyEvent,
child: MediaQuery(
data: _withFloatingKeyboard(MediaQuery.of(context)),
child: widget.child,
),
),
);
}
}
+85
View File
@@ -0,0 +1,85 @@
part of 'focus.dart';
/// A route that opens with focus on its scope moves it to the first control,
/// and focus handed to a route that another covers moves to the one on top.
class _RemoteFocusLanding extends StatefulWidget {
const _RemoteFocusLanding({required this.child});
final Widget child;
@override
State<_RemoteFocusLanding> createState() => _RemoteFocusLandingState();
}
class _RemoteFocusLandingState extends State<_RemoteFocusLanding> {
@override
void initState() {
super.initState();
FocusManager.instance.addListener(_handleFocusChange);
}
@override
void dispose() {
FocusManager.instance.removeListener(_handleFocusChange);
super.dispose();
}
void _handleFocusChange() {
final focus = FocusManager.instance.primaryFocus;
if (focus == null) {
return;
}
if (_navigatorOfCoveredRoute(focus) case final navigator?) {
_topRouteScope(navigator)?.requestFocus();
return;
}
if (focus is FocusScopeNode && focus.focusedChild == null) {
focus.nextFocus();
}
}
// Flutter hands focus to a covered route too: the first route of a navigator
// built inside one asks for the enclosing scope from Route.didAdd.
static NavigatorState? _navigatorOfCoveredRoute(FocusNode focus) {
for (final node in [focus, ...focus.ancestors]) {
final context = node.context;
if (node is FocusScopeNode &&
context != null &&
ModalRoute.isCurrentOf(context) == false) {
return Navigator.maybeOf(context);
}
}
return null;
}
static FocusScopeNode? _topRouteScope(NavigatorState navigator) {
Iterable<FocusScopeNode> childScopes(FocusNode node) sync* {
for (final child in node.children) {
if (child is FocusScopeNode) {
yield child;
} else {
yield* childScopes(child);
}
}
}
final parent = navigator.focusNode.enclosingScope;
if (parent == null) {
return null;
}
for (final scope in childScopes(parent)) {
final context = scope.context;
final route = context == null ? null : ModalRoute.of(context);
if (route != null &&
route.navigator == navigator &&
route.isCurrent &&
route.requestFocus) {
return scope;
}
}
return null;
}
@override
Widget build(BuildContext context) => widget.child;
}
+240
View File
@@ -0,0 +1,240 @@
part of 'focus.dart';
/// Once a page has no target in a direction, the search continues among the
/// nodes outside the page (sidebar, navigation bar) from the focused control's
/// own rect, so left lands on the sidebar item beside it instead of wrapping.
/// On a remote a row ends at its last control instead of jumping out of band.
class PageTraversalPolicy extends ReadingOrderTraversalPolicy
with _CrossScopeFocus {
@override
bool inDirection(FocusNode currentNode, TraversalDirection direction) {
final scope = currentNode.nearestScope;
if (scope == null) {
return super.inDirection(currentNode, direction);
}
final rowEnds =
!_isVertical(direction) &&
_isRemote(currentNode) &&
!_hasTargetInBand(scope, direction);
if (!rowEnds && super.inDirection(currentNode, direction)) {
return true;
}
final found = _findOutside(scope, currentNode.rect, direction);
if (found == null) {
return false;
}
_focusAcross(scope, found, direction);
return true;
}
static bool _hasTargetInBand(
FocusScopeNode scope,
TraversalDirection direction,
) {
final focused = scope.focusedChild;
if (focused == null) {
return true;
}
final from = focused.rect;
return scope.traversalDescendants.any(
(node) =>
node != focused &&
_isAhead(from, node.rect, direction) &&
_isInBand(from, node.rect, direction) &&
_isUncovered(node, scope),
);
}
FocusNode? _findOutside(
FocusScopeNode scope,
Rect from,
TraversalDirection direction,
) {
final root = FocusManager.instance.rootScope;
for (
var parent = scope.enclosingScope;
parent != null && parent != root;
parent = parent.enclosingScope
) {
final level = parent;
final candidates = level.traversalDescendants.where(
(node) => node != scope && _isOutside(node, scope, level),
);
final found = _nearestInDirection(from, candidates, direction);
if (found != null) {
return found;
}
}
return null;
}
static bool _isOutside(
FocusNode node,
FocusScopeNode scope,
FocusScopeNode level,
) =>
!node.ancestors.contains(scope) &&
!scope.ancestors.contains(node) &&
_isUncovered(node, level);
}
/// What a remote does outside the pages: a row or column ends at its last
/// control, and only a move into another region, as from the sidebar into the
/// page, may leave the row's band, for the nearest control on screen.
class _RemoteTraversalPolicy extends ReadingOrderTraversalPolicy
with _CrossScopeFocus {
@override
bool inDirection(FocusNode currentNode, TraversalDirection direction) {
final scope = currentNode.nearestScope;
final focused = scope?.focusedChild;
if (scope == null || focused == null) {
return super.inDirection(currentNode, direction);
}
final from = focused.rect;
final own = <FocusNode>[];
final other = <FocusNode>[];
for (final node in scope.traversalDescendants) {
if (node == focused ||
!_isAhead(from, node.rect, direction) ||
!_isUncovered(node, scope)) {
continue;
}
if (FocusTraversalGroup.maybeOfNode(node) == this) {
own.add(node);
} else if (node is! FocusScopeNode && _isOnScreen(node)) {
other.add(node);
}
}
final hasOwnTarget = _isVertical(direction)
? own.isNotEmpty
: own.any((node) => _isInBand(from, node.rect, direction));
if (hasOwnTarget) {
return super.inDirection(currentNode, direction);
}
final target = _nearestInDirection(from, other, direction);
if (target == null) {
return false;
}
_focusAcross(scope, target, direction);
return true;
}
}
mixin _CrossScopeFocus on FocusTraversalPolicy {
// Each policy keeps a history per scope so that reversing a move returns to
// where it started; a jump between scopes would leave both histories stale.
void _focusAcross(
FocusScopeNode from,
FocusNode target,
TraversalDirection direction,
) {
invalidateScopeData(from);
final targetScope = target.nearestScope;
if (targetScope != null) {
FocusTraversalGroup.maybeOfNode(target)?.invalidateScopeData(targetScope);
}
requestFocusCallback(
target,
alignmentPolicy: switch (direction) {
TraversalDirection.up || TraversalDirection.left =>
ScrollPositionAlignmentPolicy.keepVisibleAtStart,
TraversalDirection.down || TraversalDirection.right =>
ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
},
);
}
}
bool _isRemote(FocusNode node) =>
node.context?.getInheritedWidgetOfExactType<_RemoteScope>()?.enabled ??
false;
bool _isVertical(TraversalDirection direction) =>
direction == TraversalDirection.up || direction == TraversalDirection.down;
bool _isAhead(Rect from, Rect rect, TraversalDirection direction) =>
switch (direction) {
TraversalDirection.up => rect.center.dy <= from.top,
TraversalDirection.down => rect.center.dy >= from.bottom,
TraversalDirection.left => rect.center.dx <= from.left,
TraversalDirection.right => rect.center.dx >= from.right,
};
bool _isInBand(Rect from, Rect rect, TraversalDirection direction) =>
_isVertical(direction)
? rect.right > from.left && rect.left < from.right
: rect.bottom > from.top && rect.top < from.bottom;
// A covered route's scope is marked skipped; its nodes stay in the tree.
bool _isUncovered(FocusNode node, FocusScopeNode scope) {
for (final ancestor in node.ancestors) {
if (ancestor == scope) {
return true;
}
if (ancestor is FocusScopeNode && ancestor.skipTraversal) {
return false;
}
}
return true;
}
bool _isOnScreen(FocusNode node) {
final context = node.context;
for (
var scrollable = context == null ? null : Scrollable.maybeOf(context);
scrollable != null;
scrollable = Scrollable.maybeOf(scrollable.context)
) {
final box = scrollable.context.findRenderObject();
if (box is RenderBox &&
box.hasSize &&
!node.rect.overlaps(
MatrixUtils.transformRect(
box.getTransformTo(null),
Offset.zero & box.size,
),
)) {
return false;
}
}
return true;
}
FocusNode? _nearestInDirection(
Rect from,
Iterable<FocusNode> nodes,
TraversalDirection direction,
) {
final vertical = _isVertical(direction);
double along(Rect rect) => math.max(switch (direction) {
TraversalDirection.up => from.top - rect.bottom,
TraversalDirection.down => rect.top - from.bottom,
TraversalDirection.left => from.left - rect.right,
TraversalDirection.right => rect.left - from.right,
}, 0);
double across(Rect rect) => vertical
? (rect.center.dx - from.center.dx).abs()
: (rect.center.dy - from.center.dy).abs();
final aheadNodes = nodes
.where((node) => _isAhead(from, node.rect, direction))
.toList();
final banded = aheadNodes
.where((node) => _isInBand(from, node.rect, direction))
.toList();
if (banded.isNotEmpty) {
banded.sort((a, b) {
final primary = along(a.rect).compareTo(along(b.rect));
return primary != 0 ? primary : across(a.rect).compareTo(across(b.rect));
});
return banded.first;
}
if (vertical || aheadNodes.isEmpty) {
return null;
}
aheadNodes.sort((a, b) {
final primary = across(a.rect).compareTo(across(b.rect));
return primary != 0 ? primary : along(a.rect).compareTo(along(b.rect));
});
return aheadNodes.first;
}
+33 -20
View File
@@ -59,27 +59,40 @@ class SegmentedControl<T> extends StatelessWidget {
child: Semantics(
selected: option == value,
button: true,
child: Material(
type: MaterialType.transparency,
child: FocusRing(
shape: AppShape.full,
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: AppShape.full,
onTap: option == value ? null : () => onChanged(option),
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: AppTextStyle.subheadline
.of(context, emphasized: option == value)
.copyWith(
color: option == value
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
standsOff: option == value,
child: Material(
type: MaterialType.transparency,
shape: AppShape.full,
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: AppShape.full,
mouseCursor: option == value
? SystemMouseCursors.basic
: null,
splashFactory: option == value
? NoSplash.splashFactory
: null,
// A null onTap would unfocus the selected segment.
onTap: option == value
? () {}
: () => onChanged(option),
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: AppTextStyle.subheadline
.of(context, emphasized: option == value)
.copyWith(
color: option == value
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
+17 -14
View File
@@ -395,21 +395,24 @@ class _SidebarButton extends StatelessWidget {
container: true,
button: true,
selected: selected,
child: Material(
color: selected == true ? colors.selectedFill : Colors.transparent,
child: FocusRing(
shape: _itemShape,
child: InkWell(
onTap: onTap,
customBorder: _itemShape,
mouseCursor: SystemMouseCursors.basic,
splashFactory: NoSplash.splashFactory,
overlayColor: colors.overlay,
child: Tooltip(
message: tooltip,
excludeFromSemantics: !tooltipIsLabel,
child: IconTheme.merge(
data: IconThemeData(size: _iconSize, color: colors.icon),
child: SizedBox(height: height, child: child),
child: Material(
color: selected == true ? colors.selectedFill : Colors.transparent,
shape: _itemShape,
child: InkWell(
onTap: onTap,
customBorder: _itemShape,
mouseCursor: SystemMouseCursors.basic,
splashFactory: NoSplash.splashFactory,
overlayColor: colors.overlay,
child: Tooltip(
message: tooltip,
excludeFromSemantics: !tooltipIsLabel,
child: IconTheme.merge(
data: IconThemeData(size: _iconSize, color: colors.icon),
child: SizedBox(height: height, child: child),
),
),
),
),
+86
View File
@@ -137,6 +137,8 @@ extension AppSurfaceThemeExt on ThemeData {
final card = SurfaceLevel.card.colorOf(colorScheme);
final floatingLevel = SurfaceLevel.floatingOf(colorScheme);
final floating = floatingLevel.colorOf(colorScheme);
final ring = focusRingStyle(colorScheme);
final standingRing = focusRingStyle(colorScheme, standsOff: true);
return copyWith(
dividerTheme: dividerTheme.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.14),
@@ -166,6 +168,90 @@ extension AppSurfaceThemeExt on ThemeData {
backgroundColor: page,
modalBackgroundColor: page,
),
filledButtonTheme: FilledButtonThemeData(
style: standingRing.merge(filledButtonTheme.style),
),
textButtonTheme: TextButtonThemeData(
style: ring.merge(textButtonTheme.style),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: ring.merge(outlinedButtonTheme.style),
),
iconButtonTheme: IconButtonThemeData(
style: ring.merge(iconButtonTheme.style),
),
);
}
}
const _focusRingWidth = 2.0;
const _focusRingGap = 1.0;
/// The focus ring of `.agents/surfaces.md`; one that [standsOff] clears the
/// edge, which a primary fill or a button's own glass would hide it on.
BorderSide focusRingSide(ColorScheme colorScheme, {bool standsOff = false}) =>
BorderSide(
color: colorScheme.primary,
width: _focusRingWidth,
strokeAlign: standsOff
? 1 + 2 * _focusRingGap / _focusRingWidth
: BorderSide.strokeAlignInside,
);
ButtonStyle focusRingStyle(ColorScheme colorScheme, {bool standsOff = false}) =>
ButtonStyle(
side: WidgetStateProperty<BorderSide?>.fromMap({
WidgetState.focused: focusRingSide(colorScheme, standsOff: standsOff),
}),
);
/// [focusRingSide] on [shape] while focus is in [child], for ink not a button.
class FocusRing extends StatefulWidget {
const FocusRing({
super.key,
required this.shape,
this.standsOff = false,
required this.child,
});
final OutlinedBorder shape;
final bool standsOff;
final Widget child;
@override
State<FocusRing> createState() => _FocusRingState();
}
class _FocusRingState extends State<FocusRing> {
var _focused = false;
void _handleFocusChange(bool focused) {
if (focused != _focused) {
setState(() => _focused = focused);
}
}
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
onFocusChange: _handleFocusChange,
child: DecoratedBox(
position: DecorationPosition.foreground,
decoration: ShapeDecoration(
shape: widget.shape.copyWith(
side: _focused
? focusRingSide(
Theme.of(context).colorScheme,
standsOff: widget.standsOff,
)
: BorderSide.none,
),
),
child: widget.child,
),
);
}
}
+3
View File
@@ -20,6 +20,9 @@ const _allowed = {
// `CoreEventManager` is a private-constructor singleton that fans core events
// out for the whole run; closing its controller would end event delivery.
'lib/core/event.dart#_controller',
// `App` is the process-wide Android plugin; the notifier relays keyboard
// state from the platform channel for the whole run.
'lib/plugins/app.dart#floatingKeyboard',
};
final _declaration = RegExp(
+283
View File
@@ -0,0 +1,283 @@
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
final _theme = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
).withAppShapes.withAppSurfaces;
Color get _primary => _theme.colorScheme.primary;
Future<List<FocusNode>> _pump(
WidgetTester tester,
List<Widget Function(FocusNode node)> builders,
) async {
final nodes = [for (final _ in builders) FocusNode()];
for (final node in nodes) {
addTearDown(node.dispose);
}
await tester.pumpWidget(
MaterialApp(
theme: _theme,
home: Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [
for (final (index, builder) in builders.indexed)
builder(nodes[index]),
],
),
),
),
),
);
// A key press puts focus highlights in the traditional mode a remote uses.
await tester.sendKeyEvent(LogicalKeyboardKey.shift);
await tester.pumpAndSettle();
return nodes;
}
Future<void> _focus(WidgetTester tester, FocusNode node) async {
node.requestFocus();
await tester.pumpAndSettle();
}
BorderSide _buttonSide(WidgetTester tester, Key key) {
final material = tester.widget<Material>(
find.descendant(of: find.byKey(key), matching: find.byType(Material)).first,
);
return (material.shape! as OutlinedBorder).side;
}
BorderSide _ringSide(WidgetTester tester, Key key) {
final box = tester.widget<DecoratedBox>(
find
.descendant(of: find.byKey(key), matching: find.byType(DecoratedBox))
.first,
);
return ((box.decoration as ShapeDecoration).shape as OutlinedBorder).side;
}
Matcher _ring({required bool standsOff}) => isA<BorderSide>()
.having((side) => side.color, 'color', _primary)
.having((side) => side.width, 'width', 2)
.having(
(side) => side.strokeAlign,
'strokeAlign',
standsOff ? greaterThan(1) : BorderSide.strokeAlignInside,
);
void main() {
testWidgets('themed buttons ring their edge, a filled one stands off', (
tester,
) async {
const icon = Key('icon');
const text = Key('text');
const filled = Key('filled');
const outlined = Key('outlined');
final nodes = await _pump(tester, [
(node) => IconButton(
key: icon,
focusNode: node,
tooltip: 'icon',
onPressed: () {},
icon: const GlyphIcon(AppGlyphs.add),
),
(node) => TextButton(
key: text,
focusNode: node,
onPressed: () {},
child: const Text('text'),
),
(node) => FilledButton(
key: filled,
focusNode: node,
onPressed: () {},
child: const Text('filled'),
),
(node) => OutlinedButton(
key: outlined,
focusNode: node,
onPressed: () {},
child: const Text('outlined'),
),
]);
for (final key in [icon, text, filled]) {
expect(_buttonSide(tester, key).style, BorderStyle.none);
}
expect(_buttonSide(tester, outlined).width, 1);
await _focus(tester, nodes[0]);
expect(_buttonSide(tester, icon), _ring(standsOff: false));
await _focus(tester, nodes[1]);
expect(_buttonSide(tester, text), _ring(standsOff: false));
expect(_buttonSide(tester, icon).style, BorderStyle.none);
await _focus(tester, nodes[2]);
expect(_buttonSide(tester, filled), _ring(standsOff: true));
await _focus(tester, nodes[3]);
expect(_buttonSide(tester, outlined), _ring(standsOff: false));
});
testWidgets('tonal bar buttons keep the ring their own theme replaces', (
tester,
) async {
const icon = Key('icon');
const filled = Key('filled');
final nodes = await _pump(tester, [
(node) => TonalButtonTheme(
child: IconButton(
key: icon,
focusNode: node,
tooltip: 'icon',
onPressed: () {},
icon: const GlyphIcon(AppGlyphs.add),
),
),
(node) => TonalButtonTheme(
child: FilledButton(
key: filled,
focusNode: node,
onPressed: () {},
child: const Text('filled'),
),
),
]);
await _focus(tester, nodes[0]);
expect(_buttonSide(tester, icon), _ring(standsOff: false));
await _focus(tester, nodes[1]);
expect(_buttonSide(tester, filled), _ring(standsOff: true));
});
testWidgets('a focus ring follows focus into its ink control', (
tester,
) async {
const plain = Key('plain');
const standing = Key('standing');
final nodes = await _pump(tester, [
for (final (key, standsOff) in [(plain, false), (standing, true)])
(node) => FocusRing(
key: key,
shape: AppShape.full,
standsOff: standsOff,
child: InkWell(
focusNode: node,
customBorder: AppShape.full,
onTap: () {},
child: const SizedBox(width: 80, height: 40),
),
),
]);
expect(_ringSide(tester, plain).style, BorderStyle.none);
await _focus(tester, nodes[0]);
expect(_ringSide(tester, plain), _ring(standsOff: false));
await _focus(tester, nodes[1]);
expect(_ringSide(tester, plain).style, BorderStyle.none);
expect(_ringSide(tester, standing), _ring(standsOff: true));
});
testWidgets('a segment keeps focus as it selects, ringed off its pill', (
tester,
) async {
var value = 'a';
await _pump(tester, [
(_) => StatefulBuilder(
builder: (context, setState) => SizedBox(
width: 240,
child: SegmentedControl<String>(
segments: const [('a', 'a'), ('b', 'b')],
value: value,
onChanged: (next) => setState(() => value = next),
),
),
),
]);
BorderSide ringOf(String label) {
final box = tester.widget<DecoratedBox>(
find
.descendant(
of: find.ancestor(
of: find.text(label),
matching: find.byType(FocusRing),
),
matching: find.byType(DecoratedBox),
)
.first,
);
return ((box.decoration as ShapeDecoration).shape as OutlinedBorder).side;
}
final node = Focus.of(tester.element(find.text('b')));
await _focus(tester, node);
expect(ringOf('b'), _ring(standsOff: false));
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(value, 'b');
expect(node.hasPrimaryFocus, isTrue);
expect(ringOf('b'), _ring(standsOff: true));
expect(ringOf('a').style, BorderStyle.none);
});
Future<void> pumpRow(WidgetTester tester, {required bool remote}) async {
await tester.pumpWidget(
MaterialApp(
theme: _theme,
home: RemoteFocusAdapter(
enabled: remote,
child: Scaffold(
body: Column(
children: [
const TextField(key: Key('before')),
generateSection(
items: [
const DecorationListItem(
key: Key('row'),
title: TextField(key: Key('field')),
),
],
),
],
),
),
),
),
);
}
testWidgets('a row rings a field a key moved focus to, not one tapped', (
tester,
) async {
const row = Key('row');
await pumpRow(tester, remote: false);
await tester.tap(find.byKey(const Key('field')));
await tester.pumpAndSettle();
expect(_buttonSide(tester, row).style, BorderStyle.none);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pumpAndSettle();
expect(_buttonSide(tester, row).style, BorderStyle.none);
await tester.tap(find.byKey(const Key('before')));
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(_buttonSide(tester, row), _ring(standsOff: false));
await tester.tap(find.byKey(const Key('field')));
await tester.pumpAndSettle();
expect(_buttonSide(tester, row).style, BorderStyle.none);
});
testWidgets('a row rings any focus while a remote drives it', (tester) async {
await pumpRow(tester, remote: true);
await tester.showKeyboard(find.byKey(const Key('field')));
await tester.pumpAndSettle();
expect(_buttonSide(tester, const Key('row')), _ring(standsOff: false));
});
}
+369 -2
View File
@@ -1,12 +1,21 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
Widget _buildApp({required bool enabled, required Widget home}) {
Widget _buildApp({
required bool enabled,
required Widget home,
ValueListenable<bool>? floatingKeyboard,
}) {
return MaterialApp(
builder: (context, child) {
return RemoteFocusAdapter(enabled: enabled, child: child!);
return RemoteFocusAdapter(
enabled: enabled,
floatingKeyboard: floatingKeyboard,
child: child!,
);
},
home: home,
);
@@ -61,6 +70,18 @@ bool _onActionRow(WidgetTester tester) {
_hasFocus(tester, const ValueKey('submit'));
}
Widget _buildFieldsBelowButton() {
return Scaffold(
body: Column(
children: [
TextButton(onPressed: () {}, child: const Text('top')),
const TextField(key: ValueKey('name')),
const TextField(key: ValueKey('url')),
],
),
);
}
TextEditingController _controllerOf(WidgetTester tester, Key key) {
return tester
.state<EditableTextState>(
@@ -192,6 +213,112 @@ void main() {
expect(_onActionRow(tester), isTrue);
});
Widget buildList(ScrollController controller) {
return Scaffold(
body: ListView(
controller: controller,
children: [
TextButton(
key: const ValueKey('first'),
onPressed: () {},
child: const Text('first'),
),
TextButton(
key: const ValueKey('second'),
onPressed: () {},
child: const Text('second'),
),
const SizedBox(height: 2000),
],
),
);
}
Future<void> focusText(WidgetTester tester, String text) async {
Focus.of(tester.element(find.text(text))).requestFocus();
await tester.pump();
}
testWidgets('down with no control below scrolls by half a viewport', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildApp(enabled: true, home: buildList(controller)),
);
await focusText(tester, 'second');
final second = FocusManager.instance.primaryFocus;
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus, same(second));
expect(controller.offset, 300);
});
testWidgets('down that moves focus leaves the list where it is', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildApp(enabled: true, home: buildList(controller)),
);
await focusText(tester, 'first');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('second')), isTrue);
expect(controller.offset, 0);
});
testWidgets('down from a bar below the list scrolls the route list', (
tester,
) async {
await tester.pumpWidget(
_buildApp(
enabled: true,
home: Scaffold(
body: Column(
children: [
Expanded(
child: ListView(children: const [SizedBox(height: 2000)]),
),
TextButton(onPressed: () {}, child: const Text('bar')),
],
),
),
),
);
await focusText(tester, 'bar');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
final position = tester
.state<ScrollableState>(find.byType(Scrollable))
.position;
expect(position.pixels, greaterThan(0));
});
testWidgets('down with no control below stays put when disabled', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildApp(enabled: false, home: buildList(controller)),
);
await focusText(tester, 'second');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(controller.offset, 0);
});
testWidgets('arrows stay in the field when disabled', (tester) async {
await tester.pumpWidget(_buildApp(enabled: false, home: _buildForm()));
await tester.tap(find.byKey(const ValueKey('url')));
@@ -202,4 +329,244 @@ void main() {
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
});
testWidgets('a field the remote moves to opens the keyboard on select', (
tester,
) async {
await tester.pumpWidget(
_buildApp(enabled: true, home: _buildFieldsBelowButton()),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('name')), isTrue);
expect(tester.testTextInput.hasAnyClients, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('name')), isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets('a dialog the remote opens focuses its field without keyboard', (
tester,
) async {
await tester.pumpWidget(
_buildApp(
enabled: true,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () {
showDialog<void>(
context: context,
builder: (_) => const AlertDialog(
content: TextField(key: ValueKey('field'), autofocus: true),
),
);
},
child: const Text('open'),
),
),
),
),
);
await focusText(tester, 'open');
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('field')), isTrue);
expect(tester.testTextInput.hasAnyClients, isFalse);
});
testWidgets('a field tapped after using the remote opens the keyboard', (
tester,
) async {
await tester.pumpWidget(
_buildApp(enabled: true, home: _buildFieldsBelowButton()),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('url')));
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets('the next field keeps a keyboard select opened', (tester) async {
await tester.pumpWidget(
_buildApp(enabled: true, home: _buildFieldsBelowButton()),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets('a remote key after select leaves the next field closed', (
tester,
) async {
await tester.pumpWidget(
_buildApp(enabled: true, home: _buildFieldsBelowButton()),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
expect(tester.testTextInput.hasAnyClients, isFalse);
});
testWidgets('the next field keeps a keyboard that insets the view', (
tester,
) async {
addTearDown(tester.view.resetViewInsets);
await tester.pumpWidget(
_buildApp(enabled: true, home: _buildFieldsBelowButton()),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('name')));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.shiftLeft);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets('the next field keeps a floating keyboard', (tester) async {
final floating = ValueNotifier(false);
addTearDown(floating.dispose);
await tester.pumpWidget(
_buildApp(
enabled: true,
floatingKeyboard: floating,
home: _buildFieldsBelowButton(),
),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('name')));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.shiftLeft);
floating.value = true;
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(_hasFocus(tester, const ValueKey('url')), isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
testWidgets('a floating keyboard insets the screen while it is up', (
tester,
) async {
final floating = ValueNotifier(false);
addTearDown(floating.dispose);
await tester.pumpWidget(
_buildApp(
enabled: true,
floatingKeyboard: floating,
home: const SizedBox(key: ValueKey('page')),
),
);
double bottomInset() => MediaQuery.viewInsetsOf(
tester.element(find.byKey(const ValueKey('page'))),
).bottom;
floating.value = true;
await tester.pump();
expect(bottomInset(), 300);
floating.value = false;
await tester.pump();
expect(bottomInset(), 0);
});
testWidgets('a dialog field a floating keyboard covers is revealed', (
tester,
) async {
final floating = ValueNotifier(false);
addTearDown(floating.dispose);
await tester.pumpWidget(
_buildApp(
enabled: true,
floatingKeyboard: floating,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () {
showDialog<void>(
context: context,
builder: (_) => const AlertDialog(
content: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 360),
TextField(key: ValueKey('field'), autofocus: true),
],
),
),
),
);
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(
tester.getRect(find.byKey(const ValueKey('field'))).bottom,
greaterThan(300),
);
floating.value = true;
await tester.pumpAndSettle();
expect(
tester.getRect(find.byKey(const ValueKey('field'))).bottom,
lessThanOrEqualTo(300),
);
});
testWidgets('the keyboard follows focus when disabled', (tester) async {
await tester.pumpWidget(
_buildApp(enabled: false, home: _buildFieldsBelowButton()),
);
await focusText(tester, 'top');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(_hasFocus(tester, const ValueKey('name')), isTrue);
expect(tester.testTextInput.isVisible, isTrue);
});
}
+242
View File
@@ -0,0 +1,242 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
Widget _button(String label) {
return SizedBox(
width: 100,
height: 48,
child: TextButton(
key: ValueKey(label),
onPressed: () {},
child: Text(label),
),
);
}
Widget _at(double left, double top, String label) {
return Positioned(left: left, top: top, child: _button(label));
}
// Mirrors the desktop home: a sidebar beside a page that sits in a navigator of
// its own, which [showPage] can build late.
Widget _buildApp({
required bool remote,
required Widget page,
bool nested = true,
GlobalKey<NavigatorState>? navigatorKey,
ValueListenable<bool>? showPage,
}) {
Widget buildPage() {
final scoped = PageFocusScope(child: page);
if (!nested) {
return scoped;
}
return Navigator(
pages: [MaterialPage(child: scoped)],
onDidRemovePage: (_) {},
);
}
return MaterialApp(
navigatorKey: navigatorKey,
builder: (context, child) =>
RemoteFocusAdapter(enabled: remote, child: child!),
home: Scaffold(
body: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 200,
child: Column(
children: [for (var i = 0; i < 4; i++) _button('side$i')],
),
),
Expanded(
child: FocusTraversalGroup(
policy: PageTraversalPolicy(),
child: showPage == null
? buildPage()
: ValueListenableBuilder(
valueListenable: showPage,
builder: (context, show, _) =>
show ? buildPage() : const SizedBox.shrink(),
),
),
),
],
),
),
);
}
FocusNode _nodeOf(WidgetTester tester, String label) {
return Focus.of(tester.element(find.text(label, skipOffstage: false)));
}
Future<void> _focus(WidgetTester tester, String label) async {
_nodeOf(tester, label).requestFocus();
await tester.pump();
}
Future<void> _press(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.sendKeyEvent(key);
await tester.pumpAndSettle();
}
bool _hasFocus(WidgetTester tester, String label) {
return _nodeOf(tester, label).hasPrimaryFocus;
}
void main() {
testWidgets('a navigator built under a covered route leaves focus above', (
tester,
) async {
final navigatorKey = GlobalKey<NavigatorState>();
final showPage = ValueNotifier(false);
addTearDown(showPage.dispose);
await tester.pumpWidget(
_buildApp(
remote: true,
navigatorKey: navigatorKey,
showPage: showPage,
page: _button('page'),
),
);
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => Material(child: Center(child: _button('agree'))),
),
);
await tester.pumpAndSettle();
expect(_hasFocus(tester, 'agree'), isTrue);
showPage.value = true;
await tester.pumpAndSettle();
expect(_hasFocus(tester, 'agree'), isTrue);
});
testWidgets('left into the sidebar forgets the moves made before', (
tester,
) async {
await tester.pumpWidget(
_buildApp(remote: false, page: Stack(children: [_at(100, 0, 'top')])),
);
await _focus(tester, 'side3');
await _press(tester, LogicalKeyboardKey.arrowDown);
expect(_hasFocus(tester, 'top'), isTrue);
await _press(tester, LogicalKeyboardKey.arrowLeft);
expect(_hasFocus(tester, 'side0'), isTrue);
await _press(tester, LogicalKeyboardKey.arrowUp);
expect(_hasFocus(tester, 'side0'), isTrue);
});
testWidgets('the sidebar stops at its ends and leaves the page unscrolled', (
tester,
) async {
await tester.pumpWidget(
_buildApp(
remote: true,
nested: false,
page: ListView(
children: [
const SizedBox(height: 250),
_button('row'),
const SizedBox(height: 2000),
],
),
),
);
await _focus(tester, 'side3');
await _press(tester, LogicalKeyboardKey.arrowDown);
expect(_hasFocus(tester, 'side3'), isTrue);
final position = tester
.state<ScrollableState>(find.byType(Scrollable))
.position;
expect(position.pixels, 0);
await _focus(tester, 'side0');
await _press(tester, LogicalKeyboardKey.arrowUp);
expect(_hasFocus(tester, 'side0'), isTrue);
});
testWidgets('a row ends at its last control on a remote', (tester) async {
await tester.pumpWidget(
_buildApp(
remote: true,
page: Stack(
children: [
_at(0, 0, 'start'),
_at(100, 0, 'end'),
_at(300, 300, 'below'),
],
),
),
);
await _focus(tester, 'end');
await _press(tester, LogicalKeyboardKey.arrowRight);
expect(_hasFocus(tester, 'end'), isTrue);
await _focus(tester, 'below');
await _press(tester, LogicalKeyboardKey.arrowLeft);
expect(_hasFocus(tester, 'side3'), isTrue);
});
testWidgets('a row end still jumps out of band on the desktop', (
tester,
) async {
await tester.pumpWidget(
_buildApp(
remote: false,
page: Stack(children: [_at(100, 0, 'end'), _at(300, 300, 'below')]),
),
);
await _focus(tester, 'end');
await _press(tester, LogicalKeyboardKey.arrowRight);
expect(_hasFocus(tester, 'below'), isTrue);
});
testWidgets('right from the sidebar lands on a control on screen', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
_buildApp(
remote: true,
page: ListView(
controller: controller,
children: [
const SizedBox(height: 100),
_button('above'),
const SizedBox(height: 512),
_button('shown'),
const SizedBox(height: 2000),
],
),
),
);
controller.jumpTo(160);
await tester.pump();
await _focus(tester, 'above');
await _focus(tester, 'side3');
await _press(tester, LogicalKeyboardKey.arrowRight);
expect(_hasFocus(tester, 'shown'), isTrue);
expect(controller.offset, 160);
});
}