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(