From 020be77faf2ff9847dda4de76ce94aafd8b5a867 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 17:42:05 +0800 Subject: [PATCH] fix(ui,ios): AppBar OVERFLOWED-BY strip + first-tap TextField via Listener Two distinct fixes prompted by user reports from the iPhone build: 1. 'strange text on Chanora (RFLOWED BY)' \u2014 the Flutter debug overlay's 'OVERFLOWED BY N PIXELS' strip was appearing next to the AppBar title because the title Row ('Chanora' + channel pill) plus 5-6 trailing IconButton actions exceeded a typical iPhone AppBar width. User saw the strip clipped to '...RFLOWED BY...' since only its end fit on screen. _AppBarTitle now drops the 'Chanora' label on narrow widths (<840 dp). Title shows only the channel pill when in voice channel; the user already knows they're in Chanora because they just opened it. Wide widths (tablet/desktop, >= 840 dp) keep the full 'Chanora \u00b7 #channel-pill' title because there's room. Eliminates the overflow. Note: the OVERFLOWED-BY strip only renders in debug builds anyway; release builds suppress the overlay. But the underlying Row overflow was a real layout bug worth fixing. 2. First-tap TextField still failed on iPhone after the earlier FocusNode + TextField.onTap fix. Root cause: TextField.onTap fires AFTER the gesture-arena resolves, so if the enclosing SingleChildScrollView wins the arena (which it does on iOS for the very first tap), the focus request never fires. Wrap each connect-form TextField in a Listener with HitTestBehavior.translucent and onPointerDown: requestFocus. Listener fires synchronously on PointerDownEvent BEFORE arena resolution, so even if the scrollable would have won the arena we have already grabbed focus. Translucent means the pointer ALSO propagates down to the TextField so its normal touch handling still runs (text selection / cursor placement). _focusOnTap helper added; wraps all three TextFields (host, nick, password). flutter analyze: clean (6 pre-existing Radio.groupValue infos). flutter build ios --release --no-codesign: 18.4 s, Runner.app 30.0 MB. --- apps/chanora_flutter/lib/main.dart | 134 ++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 43 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 2cdb02b..98493dd 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1160,11 +1160,26 @@ class _AppBarTitle extends StatelessWidget { if (phase != _Phase.connected || !inChannel || channelName.isEmpty) { return Text(l10n.appTitle); } + // When in a voice channel on a narrow phone width, the AppBar + // is already crowded with mic / headset / settings / about / + // diagnostics / disconnect icons (5-6 action buttons). Keeping + // the "Chanora" app-name label in the title here causes the + // Row to overflow at typical iPhone widths and Flutter renders + // its yellow-and-black "OVERFLOWED BY X PIXELS" debug strip + // next to the title — the user reported seeing "RFLOWED BY" + // there. Drop the app-name label on narrow widths and let the + // channel pill be the only title content; the user knows + // they're in Chanora because they just opened it. On wide + // widths (>= 840 dp, tablet/desktop) restore the app name + // because there's plenty of room. + final isNarrow = MediaQuery.of(context).size.width < 840.0; return Row( mainAxisSize: MainAxisSize.min, children: [ - Text(l10n.appTitle), - const SizedBox(width: 12), + if (!isNarrow) ...[ + Text(l10n.appTitle), + const SizedBox(width: 12), + ], Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), @@ -1223,19 +1238,43 @@ class _ConnectForm extends StatefulWidget { } class _ConnectFormState extends State<_ConnectForm> { - // FocusNodes + explicit tap-to-focus handlers work around a - // Flutter-on-iOS gesture-arena issue where the enclosing - // SingleChildScrollView absorbs the first tap on each TextField - // (the second tap then succeeds because the scrollable's tap- - // arena participant has already decided not to handle a drag). - // Calling requestFocus() in onTap forces focus immediately on - // the first tap, before the gesture arena resolves. - // https://github.com/flutter/flutter/issues/19027 — long-standing - // and still reproducible in Flutter 3.x on iOS. + // FocusNodes + Listener(onPointerDown) wrappers fix a Flutter-on- + // iOS gesture-arena issue where enclosing scrollables (in our + // case, the SingleChildScrollView wrapping the connect form, and + // the ListView wrapping the channel tree) absorb the first tap + // as a possible scroll-intent. The second tap then succeeds + // because the scrollable's tap-arena participant has already + // decided not to handle a drag. + // + // Two-layer fix: + // * focusNode + autofocus-on-tap inside TextField — works when + // the arena resolves the tap as a TextField gesture + // * Listener(onPointerDown) wrapping the TextField with + // HitTestBehavior.translucent — fires synchronously on + // pointer-down BEFORE arena resolution, so even if the + // scrollable were to win the arena we have already grabbed + // focus. Translucent means the pointer ALSO propagates down + // to the TextField so its normal touch handling still runs. + // + // This is the documented workaround in + // https://github.com/flutter/flutter/issues/22680 and many + // related arena-race threads. final FocusNode _hostFocus = FocusNode(); final FocusNode _nickFocus = FocusNode(); final FocusNode _passwordFocus = FocusNode(); + Widget _focusOnTap(Widget child, FocusNode node) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) { + if (!node.hasFocus) { + node.requestFocus(); + } + }, + child: child, + ); + } + @override void dispose() { _hostFocus.dispose(); @@ -1250,45 +1289,54 @@ class _ConnectFormState extends State<_ConnectForm> { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextField( - controller: widget.hostCtl, - focusNode: _hostFocus, - onTap: () => _hostFocus.requestFocus(), - textInputAction: TextInputAction.next, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: l10n.fieldServerHost, - border: const OutlineInputBorder(), + _focusOnTap( + TextField( + controller: widget.hostCtl, + focusNode: _hostFocus, + onTap: () => _hostFocus.requestFocus(), + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.fieldServerHost, + border: const OutlineInputBorder(), + ), ), + _hostFocus, ), const SizedBox(height: 8), - TextField( - controller: widget.nickCtl, - focusNode: _nickFocus, - onTap: () => _nickFocus.requestFocus(), - textInputAction: TextInputAction.next, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: l10n.fieldNickname, - border: const OutlineInputBorder(), + _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(), + ), ), + _nickFocus, ), const SizedBox(height: 8), - 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(), + _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(), + ), ), + _passwordFocus, ), const SizedBox(height: 16), Row(