diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 4eeb489..d384a64 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1266,42 +1266,36 @@ class _ConnectFormState extends State<_ConnectForm> { final FocusNode _nickFocus = FocusNode(); final FocusNode _passwordFocus = FocusNode(); - Widget _focusOnTap(Widget child, FocusNode node) { - // iOS first-tap-not-focusing workaround. - // - // Symptom: the user taps a TextField the first time after the - // app launches (or after the field has not held focus this - // session); the cursor doesn't appear and the on-screen keyboard - // doesn't slide up. A second tap then works normally. - // - // Root cause: Flutter's EditableText opens the platform - // TextInput channel (which is what actually slides the iOS - // keyboard up) only after a TapGestureRecognizer **wins** the - // gesture arena. Our outer Listener (HitTestBehavior.translucent, - // onPointerDown) used to call node.requestFocus() pre-arena, - // which marks the node focused in Flutter's tree but does not - // open the TextInput channel \u2014 so the OS keyboard stays hidden - // until the EditableText's own tap recognizer wins on a second - // tap. - // - // Fix: in addition to requestFocus(), invoke 'TextInput.show' - // on the SystemChannels.textInput method channel. This is the - // same private RPC EditableText uses internally; calling it - // ourselves forces the OS keyboard up regardless of arena state. - // Belt-and-braces with the Listener guarantees keyboard-on-first- - // tap on iPhone / iPad and is harmless on Android (the platform - // ignores the redundant show call when the keyboard is already - // up) and on desktop (no soft keyboard exists). - return Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) { - if (!node.hasFocus) { - node.requestFocus(); - } - SystemChannels.textInput.invokeMethod('TextInput.show'); - }, - child: child, - ); + /// Workaround for flutter/flutter#181474 (open, P2, triaged-text- + /// input, iOS 26+): after the soft keyboard is dismissed, the + /// FocusNode still reports `hasFocus == true`. Because the node is + /// already focused, the next user tap on the same TextField does + /// not trigger any focus-change side effect, so the platform + /// TextInput channel is never re-opened, and iOS keeps the soft + /// keyboard hidden. The user has to tap twice (the second tap + /// triggers an explicit re-focus path that re-shows the keyboard). + /// + /// The community-recommended workaround in the issue thread is to + /// **unfocus first, then re-request focus** on every tap. That + /// forces a real focus-change transition (`false -> true`) which + /// re-opens TextInput and slides the keyboard up on the first tap. + /// + /// This costs nothing on hosts where the bug doesn't reproduce + /// (the unfocus call is a no-op when the node isn't focused; the + /// re-request just reaffirms focus and EditableText's own attach + /// path handles the rest). + void _kickFocus(FocusNode node) { + // Drop focus synchronously, then re-grab it on the next frame. + // Doing both in one frame can race with EditableText's internal + // bookkeeping; deferring the re-grab by one microtask sidesteps + // that and matches the workaround pattern in the issue thread. + if (node.hasFocus) { + node.unfocus(); + } + Future.microtask(() { + if (!mounted) return; + node.requestFocus(); + }); } @override @@ -1318,85 +1312,76 @@ class _ConnectFormState extends State<_ConnectForm> { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _focusOnTap( - TextField( - controller: widget.hostCtl, - focusNode: _hostFocus, - onTap: () => _hostFocus.requestFocus(), - // Server addresses are URL-shaped: hostname or - // hostname:port, all-lowercase ASCII, never user- - // friendly prose. Configure the on-screen keyboard - // accordingly: - // * keyboardType: .url \u2014 surfaces ".", "/", - // ":" on the primary keyboard plane so the user - // doesn't have to switch to the symbols pane to - // type kr.teamspeak.app:9987. - // * textInputAction: .next \u2014 return key advances - // to the nickname field. - // * autocorrect / enableSuggestions: false \u2014 iOS - // should not autocorrect 'kr.teamspeak.app' to - // 'kr.teamspeak.lap' or suggest 'KR' in caps. - // * textCapitalization: .none \u2014 don't capitalise the - // first letter the way iOS does for sentences. - // * inputFormatters: deny whitespace and uppercase\u2014 - // belt-and-braces in case the user pastes from a - // formatted source (e.g. tab-indented copy). - keyboardType: TextInputType.url, - textCapitalization: TextCapitalization.none, - textInputAction: TextInputAction.next, - autocorrect: false, - enableSuggestions: false, - inputFormatters: [ - FilteringTextInputFormatter.deny(RegExp(r'\s')), - TextInputFormatter.withFunction( - (oldValue, newValue) => newValue.copyWith( - text: newValue.text.toLowerCase(), - selection: newValue.selection, - ), + TextField( + controller: widget.hostCtl, + focusNode: _hostFocus, + onTap: () => _kickFocus(_hostFocus), + // Server addresses are URL-shaped: hostname or + // hostname:port, all-lowercase ASCII, never user- + // friendly prose. Configure the on-screen keyboard + // accordingly: + // * keyboardType: .url \u2014 surfaces ".", "/", + // ":" on the primary keyboard plane so the user + // doesn't have to switch to the symbols pane to + // type kr.teamspeak.app:9987. + // * textInputAction: .next \u2014 return key advances + // to the nickname field. + // * autocorrect / enableSuggestions: false \u2014 iOS + // should not autocorrect 'kr.teamspeak.app' to + // 'kr.teamspeak.lap' or suggest 'KR' in caps. + // * textCapitalization: .none \u2014 don't capitalise the + // first letter the way iOS does for sentences. + // * inputFormatters: deny whitespace and uppercase\u2014 + // belt-and-braces in case the user pastes from a + // formatted source (e.g. tab-indented copy). + keyboardType: TextInputType.url, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp(r'\s')), + TextInputFormatter.withFunction( + (oldValue, newValue) => newValue.copyWith( + text: newValue.text.toLowerCase(), + selection: newValue.selection, ), - ], - decoration: InputDecoration( - labelText: l10n.fieldServerHost, - hintText: 'host[:port]', - prefixIcon: const Icon(Icons.dns_outlined), - border: const OutlineInputBorder(), ), + ], + decoration: InputDecoration( + labelText: l10n.fieldServerHost, + hintText: 'host[:port]', + prefixIcon: const Icon(Icons.dns_outlined), + border: const OutlineInputBorder(), ), - _hostFocus, ), const SizedBox(height: 8), - _focusOnTap( - TextField( - controller: widget.nickCtl, - focusNode: _nickFocus, - onTap: () => _nickFocus.requestFocus(), - textInputAction: TextInputAction.next, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: l10n.fieldNickname, - border: const OutlineInputBorder(), - ), + TextField( + controller: widget.nickCtl, + focusNode: _nickFocus, + onTap: () => _kickFocus(_nickFocus), + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.fieldNickname, + border: const OutlineInputBorder(), ), - _nickFocus, ), const SizedBox(height: 8), - _focusOnTap( - TextField( - controller: widget.passwordCtl, - focusNode: _passwordFocus, - onTap: () => _passwordFocus.requestFocus(), - obscureText: true, - textInputAction: TextInputAction.done, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: l10n.fieldServerPassword, - helperText: l10n.fieldServerPasswordHelp, - border: const OutlineInputBorder(), - ), + TextField( + controller: widget.passwordCtl, + focusNode: _passwordFocus, + onTap: () => _kickFocus(_passwordFocus), + obscureText: true, + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.fieldServerPassword, + helperText: l10n.fieldServerPasswordHelp, + border: const OutlineInputBorder(), ), - _passwordFocus, ), const SizedBox(height: 16), Row(