diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index c90fcf7..a81b6c0 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1315,6 +1315,23 @@ class _ConnectFormState extends State<_ConnectForm> { // // and wire onTap: () => _kickFocus(_xxxFocus) on each TextField. + /// Dismisses the soft keyboard when the user taps outside any + /// TextField in this form. Wired into each TextField via the + /// built-in `onTapOutside` parameter (Flutter 3.10+), which uses + /// the framework's TapRegion machinery to detect taps outside the + /// field's region without us having to install an outer + /// GestureDetector (which previously caused a + /// '_dependents.isEmpty' assertion when modal-sheet pop sequences + /// raced the InheritedWidget dependency cleanup). + /// + /// Uses FocusManager.instance \u2014 a global singleton with no + /// BuildContext dependency \u2014 instead of FocusScope.of(context), + /// to avoid subscribing this widget's Element to any + /// InheritedWidget. + void _onTapOutside(PointerDownEvent _) { + FocusManager.instance.primaryFocus?.unfocus(); + } + @override void dispose() { _hostFocus.dispose(); @@ -1328,32 +1345,46 @@ class _ConnectFormState extends State<_ConnectForm> { final l10n = AppL10n.of(context); // Tap-outside-to-unfocus. // - // Initial implementation used HitTestBehavior.translucent which - // dispatched the tap to BOTH this GestureDetector and any - // descendant TextField. That meant every field tap fired - // FocusScope.unfocus() at the same time the TextField's own - // gesture path was attaching TextInput \u2014 the two animations - // raced each other, producing the user-visible 500\u20131000 ms lag + - // 'stuck' keyboard behaviour reported on iPhone iOS 18. + // Iteration history: + // * 020be77/23bd1c7: Listener pre-arena requestFocus + + // SystemChannels.textInput.show. Caused first-tap-not-focusing + // AND post-attach keystroke lag (Listener wins arena, + // EditableText loses). + // * 79f8360: _kickFocus(unfocus + Future.microtask refocus) on + // every TextField.onTap. Caused 500-1000 ms keyboard slide-up + // lag (microtask deferral collides with iOS keyboard + // CAAnimation). + // * 6955547: GestureDetector(HitTestBehavior.opaque, onTap: + // FocusScope.of(context).unfocus()) wrapping the Column. + // Caused 'package:flutter/src/widgets/framework.dart line + // 6268 _dependents.isEmpty' assertion: the + // FocusScope.of(context) call subscribes the GestureDetector's + // Element to the _FocusScopeMarker InheritedWidget on every + // build, and the modal-sheet pop sequence deactivates that + // InheritedElement before the dependents fully clear, + // tripping the debug assert. // - // Fixed by switching to HitTestBehavior.opaque. With opaque, the - // gesture arena still routes a tap on a TextField to that - // TextField's own recognizer (only one recognizer wins), so the - // outer onTap does NOT fire when the user taps a field. The - // outer onTap only fires when the user taps somewhere this - // GestureDetector receives that no child claimed \u2014 i.e. the - // empty padding between fields, the spacers, or below the - // bottom button row. That's exactly the tap-outside semantic we - // want, with zero interference on field taps. - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => FocusScope.of(context).unfocus(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + // Current approach (no outer GestureDetector at all): + // * Use TextField.onTapOutside (added Flutter 3.10+). Wired + // per field. Uses FocusManager.instance (global, no + // BuildContext dependency, no InheritedWidget race). + // * iOS framework default for touch+mobile is to do NOTHING + // on tap-outside (see flutter/widgets/editable_text.dart + // _EditableTextTapOutsideAction:6748-6755) because the iOS + // UX convention is 'swipe-down on the keyboard' or 'tap + // Done' \u2014 not tap-outside. We override that explicitly + // because most users expect tap-outside-to-dismiss in a + // server-connect form context. + // * No outer GestureDetector means zero gesture-arena + // interference with the TextField's own tap recognizer \u2014 + // keyboard appears synchronously on first tap, no lag. + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ TextField( controller: widget.hostCtl, focusNode: _hostFocus, + onTapOutside: _onTapOutside, // Server addresses are URL-shaped: hostname or // hostname:port, all-lowercase ASCII, never user- // friendly prose. Configure the on-screen keyboard @@ -1397,6 +1428,7 @@ class _ConnectFormState extends State<_ConnectForm> { TextField( controller: widget.nickCtl, focusNode: _nickFocus, + onTapOutside: _onTapOutside, textInputAction: TextInputAction.next, autocorrect: false, enableSuggestions: false, @@ -1409,6 +1441,7 @@ class _ConnectFormState extends State<_ConnectForm> { TextField( controller: widget.passwordCtl, focusNode: _passwordFocus, + onTapOutside: _onTapOutside, obscureText: true, textInputAction: TextInputAction.done, autocorrect: false, @@ -1438,7 +1471,6 @@ class _ConnectFormState extends State<_ConnectForm> { ], ), ], - ), ); } }