feat(ui): polish Android TV remote focus and typing
Changelog: Clearer focus and typing on Android TV
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user