fix: align UI focus and sheet behavior

This commit is contained in:
edde746
2026-07-13 11:28:32 +02:00
parent e6e7d8cdfd
commit e4db04fa62
82 changed files with 1860 additions and 1216 deletions
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_slider.dart';
void main() {
testWidgets('D-pad adjustment reports a complete persisted change', (tester) async {
final focusNode = FocusNode(debugLabel: 'slider');
addTearDown(focusNode.dispose);
final starts = <double>[];
final changes = <double>[];
final ends = <double>[];
var value = 0.0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => FocusableSlider(
focusNode: focusNode,
value: value,
min: 0,
max: 10,
divisions: 10,
onChangeStart: starts.add,
onChanged: (next) {
changes.add(next);
setState(() => value = next);
},
onChangeEnd: ends.add,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(starts, [0.0]);
expect(changes, [1.0]);
expect(ends, [1.0]);
expect(value, 1.0);
});
testWidgets('SELECT invokes the slider action once', (tester) async {
final focusNode = FocusNode(debugLabel: 'slider');
addTearDown(focusNode.dispose);
var selected = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableSlider(focusNode: focusNode, value: 0, onChanged: (_) {}, onSelect: () => selected++),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(selected, 1);
});
}
+26
View File
@@ -12,6 +12,7 @@ void main() {
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
BackKeyUpSuppressor.clearSuppression();
BackKeyCoordinator.clear();
});
testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async {
@@ -74,6 +75,31 @@ void main() {
await tester.pump();
});
group('BackKeyCoordinator', () {
testWidgets('suppresses one parallel back dispatch in the current frame', (tester) async {
BackKeyCoordinator.markHandled();
expect(BackKeyCoordinator.consumeIfHandled(), isTrue);
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
await tester.pump();
});
testWidgets('does not suppress an independent system back in a later frame', (tester) async {
BackKeyCoordinator.markHandled();
await tester.pump();
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
});
testWidgets('clear discards a pending duplicate marker', (tester) async {
BackKeyCoordinator.markHandled();
BackKeyCoordinator.clear();
expect(BackKeyCoordinator.consumeIfHandled(), isFalse);
await tester.pump();
});
});
group('dpadKeyHandler trapHorizontalEdges', () {
testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async {
final trapped = FocusNode(debugLabel: 'trapped');
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
void main() {
testWidgets('autofocus and back routing are forwarded to the focus wrapper', (tester) async {
final focusNode = FocusNode(debugLabel: 'dialog action');
addTearDown(focusNode.dispose);
var backed = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialogActionButton(
focusNode: focusNode,
autofocus: true,
onPressed: () {},
onBack: () => backed++,
label: 'Save',
),
),
),
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
expect(backed, 1);
});
testWidgets('nullable callback keeps its graph position while disabling activation', (tester) async {
final focusNode = FocusNode(debugLabel: 'disabled dialog action');
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialogActionButton(
focusNode: focusNode,
autofocus: true,
onPressed: null,
label: 'Unavailable',
isPrimary: true,
),
),
),
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(focusNode.hasFocus, isTrue);
});
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
void main() {
testWidgets('switch tile toggles once from SELECT', (tester) async {
final focusNode = FocusNode(debugLabel: 'switch');
addTearDown(focusNode.dispose);
var value = false;
var changes = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => FocusableSwitchListTile(
focusNode: focusNode,
value: value,
title: const Text('Switch'),
onChanged: (next) {
changes++;
setState(() => value = next);
},
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(value, isTrue);
expect(changes, 1);
});
testWidgets('checkbox tile toggles once from SELECT', (tester) async {
final focusNode = FocusNode(debugLabel: 'checkbox');
addTearDown(focusNode.dispose);
var value = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => FocusableCheckboxListTile(
focusNode: focusNode,
value: value,
title: const Text('Checkbox'),
onChanged: (next) => setState(() => value = next ?? false),
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(value, isTrue);
});
testWidgets('disabled switch tile cannot be focused or activated', (tester) async {
final focusNode = FocusNode(debugLabel: 'disabled switch');
addTearDown(focusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableSwitchListTile(
focusNode: focusNode,
value: false,
title: const Text('Disabled'),
onChanged: null,
),
),
),
);
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isFalse);
});
}
+16
View File
@@ -240,5 +240,21 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
});
testWidgets('system back in a later frame is not mistaken for a duplicate TV key', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(find.text('Open'), findsOneWidget);
expect(backs, 0);
});
});
}
+20 -14
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
@@ -524,20 +525,22 @@ void main() {
var parentBuilds = 0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: Builder(
builder: (context) {
parentBuilds++;
return NavigationRailItem(
icon: Symbols.home_rounded,
label: const Text('Home'),
isSelected: false,
onTap: () {},
focusNode: focusNode,
);
},
InputModeTracker(
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: Builder(
builder: (context) {
parentBuilds++;
return NavigationRailItem(
icon: Symbols.home_rounded,
label: const Text('Home'),
isSelected: false,
onTap: () {},
focusNode: focusNode,
);
},
),
),
),
),
@@ -547,6 +550,9 @@ void main() {
expect(_railItemDecoration(tester, item)?.color, isNull);
expect(parentBuilds, 1);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
focusNode.requestFocus();
await tester.pump();