diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 8a3f25f6..de217496 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -648,6 +648,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { late final VoidCallback _focusListener = _handleFocusChanged; final Object _nativeFocusToken = Object(); bool _reportedNativeTextInputFocused = false; + TvVirtualKeyboardHandle? _tvKeyboardHandle; bool _tvKeyboardOpen = false; bool _tvKeyboardOpenScheduled = false; bool _suppressTvKeyboardAutoOpen = false; @@ -661,6 +662,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { void didUpdateWidget(_FocusableTextInputHost oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.input.focusNode != widget.input.focusNode) { + // An open keyboard dialog intentionally survives rebuilds and focusNode + // swaps; it is closed only when this host unmounts — see dispose. _restoreInstalledHandler(); _suppressTvKeyboardAutoOpen = false; _tvKeyboardOpenScheduled = false; @@ -673,6 +676,13 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { @override void dispose() { _restoreInstalledHandler(); + // The keyboard is a navigator route — it must not outlive the field that + // opened it (e.g. a form section swapped out while the keyboard is up). + // Navigator mutation is unsafe during tree finalization; defer a frame. + final keyboard = _tvKeyboardHandle; + if (keyboard != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => keyboard.close()); + } _ownedFocusNode?.dispose(); super.dispose(); } @@ -763,31 +773,38 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { // so only static configuration may be snapshotted here — the callbacks // must resolve against widget.input at invoke time. final input = widget.input; + final keyboard = showTvVirtualKeyboard( + context: context, + controller: input.controller, + hintText: _keyboardHint(input.decoration), + keyboardType: input.keyboardType, + textInputAction: input.textInputAction, + inputFormatters: input.inputFormatters, + obscureText: input.obscureText, + maxLength: input.maxLength, + maxLines: input.maxLines, + onChanged: (text) { + if (!mounted) return; + widget.input.onChanged?.call(text); + }, + onSubmitted: (text) { + if (!mounted) return; + final current = widget.input; + if (current.onSubmitted != null) { + current.onSubmitted!(text); + } else { + current._handleTvKeyboardAction(); + } + }, + ); + if (keyboard == null) { + _tvKeyboardOpen = false; + return; + } + _tvKeyboardHandle = keyboard; unawaited( - showTvVirtualKeyboard( - context: context, - controller: input.controller, - hintText: _keyboardHint(input.decoration), - keyboardType: input.keyboardType, - textInputAction: input.textInputAction, - inputFormatters: input.inputFormatters, - obscureText: input.obscureText, - maxLength: input.maxLength, - maxLines: input.maxLines, - onChanged: (text) { - if (!mounted) return; - widget.input.onChanged?.call(text); - }, - onSubmitted: (text) { - if (!mounted) return; - final current = widget.input; - if (current.onSubmitted != null) { - current.onSubmitted!(text); - } else { - current._handleTvKeyboardAction(); - } - }, - ).whenComplete(() { + keyboard.closed.whenComplete(() { + _tvKeyboardHandle = null; if (!mounted) return; _tvKeyboardOpen = false; WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index e7f0ea9e..8348919f 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -41,7 +43,31 @@ void warmUpTvVirtualKeyboardText(BuildContext context) { } } -Future showTvVirtualKeyboard({ +/// Handle to a TV virtual keyboard pushed by [showTvVirtualKeyboard]. +class TvVirtualKeyboardHandle { + TvVirtualKeyboardHandle._(this._navigator, this._route); + + final NavigatorState _navigator; + final DialogRoute _route; + + /// Completes when the keyboard leaves the navigator — submit, cancel, + /// barrier/back dismiss, or [close]. + Future get closed => _route.popped; + + /// Dismiss the keyboard if it is still up. No-ops once the route is gone + /// or the navigator is being torn down. + void close() { + if (!_navigator.mounted) return; + if (_route.isCurrent) { + _navigator.pop(); + } else if (_route.isActive) { + // removeRoute also completes route.popped, which backs [closed]. + _navigator.removeRoute(_route); + } + } +} + +TvVirtualKeyboardHandle? showTvVirtualKeyboard({ required BuildContext context, required TextEditingController controller, String? hintText, @@ -55,13 +81,19 @@ Future showTvVirtualKeyboard({ ValueChanged? onSubmitted, VoidCallback? onAction, }) { - if (!PlatformDetector.isTV()) return Future.value(); + if (!PlatformDetector.isTV()) return null; - return showDialog( + // A hand-built DialogRoute instead of showDialog so the caller gets a + // handle it can close when the owning field unmounts. Mirrors showDialog's + // root-navigator, captured-themes and closed-loop traversal defaults. + final navigator = Navigator.of(context, rootNavigator: true); + final route = DialogRoute( context: context, barrierDismissible: true, barrierColor: Colors.black.withValues(alpha: 0.1), useSafeArea: false, + themes: InheritedTheme.capture(from: context, to: navigator.context), + traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, builder: (context) => _TvVirtualKeyboardDialog( controller: controller, hintText: hintText, @@ -76,6 +108,8 @@ Future showTvVirtualKeyboard({ onAction: onAction, ), ); + unawaited(navigator.push(route)); + return TvVirtualKeyboardHandle._(navigator, route); } enum _TvKeyType { spacer, character, shift, symbols, space, newline, backspace, clear, cancel, done } diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 7b1c0aba..0f7a40d5 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -234,6 +234,38 @@ void main() { expect(find.byType(Dialog), findsOneWidget); }); + testWidgets('TV virtual keyboard closes when its owning field unmounts', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + Future pumpField({required bool present}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: present + ? FocusableTextField(controller: controller, focusNode: fieldFocusNode) + : const SizedBox.shrink(), + ), + ), + ); + } + + await pumpField(present: true); + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsOneWidget); + + // Swap the field out while the keyboard is up — the keyboard must follow. + await pumpField(present: false); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsNothing); + }); + testWidgets('TV virtual keyboard does not immediately reopen after dismissal', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); await _setTvSurfaceSize(tester); diff --git a/test/widgets/tv_virtual_keyboard_test.dart b/test/widgets/tv_virtual_keyboard_test.dart index 32c533e2..da179d87 100644 --- a/test/widgets/tv_virtual_keyboard_test.dart +++ b/test/widgets/tv_virtual_keyboard_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -167,7 +166,7 @@ void main() { ), ); - unawaited(showTvVirtualKeyboard(context: context, controller: controller)); + showTvVirtualKeyboard(context: context, controller: controller); await tester.pumpAndSettle(); expect(find.byType(Dialog), findsOneWidget); @@ -208,14 +207,12 @@ Future _pumpKeyboard( ), ); - unawaited( - showTvVirtualKeyboard( - context: context, - controller: controller, - keyboardType: keyboardType, - maxLines: maxLines, - onSubmitted: onSubmitted, - ), + showTvVirtualKeyboard( + context: context, + controller: controller, + keyboardType: keyboardType, + maxLines: maxLines, + onSubmitted: onSubmitted, ); await tester.pumpAndSettle(); }