Fix avatar menu's missing border and put the tab title beside search
Build APK / build (push) Successful in 5m36s

- The avatar menu card's border was going missing wherever an opaque
  row sat against it (every corner but the header's) - a Container
  combining `border` with its own `clipBehavior` paints the border as
  part of the outer decoration, then the clipped child on top right up
  to the same boundary, with no gap for the border's stroke to show
  through. Fixed with the standard pattern: no clipBehavior on the
  bordered Container, a 1px-inset ClipRRect around the filled, clipped
  content instead.
- NooTopBar.androidTitleTrailing (renamed from androidTitleReplacement)
  now sits beside the tab title instead of replacing it - the title
  keeps its natural width (Flexible, ellipsizing only if truly out of
  room) with the inline search bar taking the remaining space to its
  right, rather than the title disappearing whenever search moves into
  the top bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 02:16:30 -04:00
co-authored by Claude Sonnet 5
parent 9f47d0a3e3
commit fb4829d035
4 changed files with 147 additions and 101 deletions
+17 -5
View File
@@ -245,11 +245,23 @@ blocks are noted where they matter:
near-black `bg` (a dark, diffuse shadow needs real density close to the near-black `bg` (a dark, diffuse shadow needs real density close to the
edge to be visible against an already-dark backdrop), so a second, edge to be visible against an already-dark backdrop), so a second,
tighter, more opaque contact shadow underneath it gives real elevation tighter, more opaque contact shadow underneath it gives real elevation
in both themes. Also added `NooTopBar.androidTitleReplacement`: Android has no in both themes. The card's border can go missing wherever an opaque row
large title to put a second search row under the way iOS's `search:` sits against it too (every corner but the header's, which has no
slot does, so an inline search bar (`AppTopBar` passes a plain full-bleed fill of its own) if a `Container` combines `border` with its
`ShellSearchLauncher()` when search isn't in the bottom bar) replaces the own `clipBehavior` - that paints the border as part of the *outer*
title in its flexible slot instead of sitting beside it as a bare icon. decoration, then the clipped child on top right up to the same boundary,
with no gap for the border's own stroke to show through. Fixed the same
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
reduced by that same 1px) around the filled, clipped content instead, so
it never paints over the border. Also added
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
second search row under the way iOS's `search:` slot does, so an inline
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
search isn't in the bottom bar) sits to the title's own right in that
flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/
ellipsizes if there's truly no room but doesn't claim more than it needs
otherwise - instead of the title being replaced by it.
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header with the persistent sync-status the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including chip and pull-to-refresh gesture/spinner. Every screen (including
+6 -6
View File
@@ -16,9 +16,9 @@ import 'shell/shell_common.dart';
/// title, then an inline search field that opens [SearchView]. /// title, then an inline search field that opens [SearchView].
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
/// here - it's the [NooFab] `MainShellView` shows instead. When search /// here - it's the [NooFab] `MainShellView` shows instead. When search
/// isn't in the bottom bar, the title itself is replaced by an inline /// isn't in the bottom bar, an inline search-field-styled launcher
/// search-field-styled launcher (`androidTitleReplacement`) rather than /// (`androidTitleTrailing`) sits to the title's own right, rather than a
/// a bare search icon sitting beside it. /// bare search icon over in `actions`.
/// ///
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
/// read from context here, since [preferredSize] is a plain getter with no /// read from context here, since [preferredSize] is a plain getter with no
@@ -68,9 +68,9 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
? () => Scaffold.of(context).openDrawer() ? () => Scaffold.of(context).openDrawer()
: null, : null,
// Android has no large title to put a second search row under the // Android has no large title to put a second search row under the
// way iOS does (`search:` below) - an inline bar replaces the title // way iOS does (`search:` below) - an inline bar sits beside the
// itself instead of sitting beside it as a plain icon action. // title instead, rather than a plain search icon over in `actions`.
androidTitleReplacement: !_ios && _showSearch androidTitleTrailing: !_ios && _showSearch
? const ShellSearchLauncher() ? const ShellSearchLauncher()
: null, : null,
actions: [ actions: [
+22 -4
View File
@@ -75,9 +75,20 @@ class _AvatarMenuContent extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: Container( child: Container(
clipBehavior: Clip.antiAlias, // No `color`/`clipBehavior` here - a `Container` with both a
// `border` and `clipBehavior` set paints the border as part
// of its *outer* decoration, then paints its (clipped) child
// on top right up to that same boundary with no gap for the
// border's own stroke width to show through. An opaque child
// touching that edge - every row below has its own full-bleed
// `Material` fill - then paints straight over the inner half
// of the border, which read as the border going missing
// specifically wherever an opaque row sits (every corner but
// the two by the header, which has no opaque fill of its
// own). The 1px `Padding` + inset `ClipRRect` below keeps the
// clipped, filled content entirely inside the border's own
// stroke instead of racing it for the same pixels.
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line), border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(NooRadii.card), borderRadius: BorderRadius.circular(NooRadii.card),
// `nooDialogShadow` alone is a wide, soft, fairly faint // `nooDialogShadow` alone is a wide, soft, fairly faint
@@ -99,8 +110,12 @@ class _AvatarMenuContent extends StatelessWidget {
nooDialogShadow, nooDialogShadow,
], ],
), ),
child: Padding(
padding: const EdgeInsets.all(1),
child: ClipRRect(
borderRadius: BorderRadius.circular(NooRadii.card - 1),
child: Material( child: Material(
color: Colors.transparent, color: colors.surface,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -116,7 +131,8 @@ class _AvatarMenuContent extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
session.username, session.username,
@@ -180,6 +196,8 @@ class _AvatarMenuContent extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
+28 -12
View File
@@ -56,14 +56,15 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
/// iOS only: the search field shown below the large title. /// iOS only: the search field shown below the large title.
final Widget? search; final Widget? search;
/// Android only: replaces the plain title text with an arbitrary widget /// Android only: an extra widget (e.g. a search-field-styled launcher) in
/// (e.g. a search-field-styled launcher) in that same flexible middle /// the flexible middle slot, to the title's own right - [title] keeps
/// slot, between [leading]/the menu button and [actions]. iOS already /// its natural (shrink-to-fit, ellipsized) width on the left, and this
/// has a dedicated [search] slot below its large title for this; Android /// takes whatever space is left rather than replacing it. iOS already has
/// has no large title to put a second row under, so a caller that wants /// a dedicated [search] slot below its large title for this; Android has
/// an inline search bar there replaces the title with one instead of /// no large title to put a second row under, so a caller that wants an
/// adding a row. /// inline search bar there puts it beside the title instead of adding a
final Widget? androidTitleReplacement; /// row.
final Widget? androidTitleTrailing;
/// Defaults to `bg` - the top bar sits on the screen background. /// Defaults to `bg` - the top bar sits on the screen background.
final Color? backgroundColor; final Color? backgroundColor;
@@ -76,7 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
this.onMenu, this.onMenu,
this.actions = const [], this.actions = const [],
this.search, this.search,
this.androidTitleReplacement, this.androidTitleTrailing,
this.backgroundColor, this.backgroundColor,
}); });
@@ -188,10 +189,25 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
spacing: 4, spacing: 4,
children: [ children: [
?lead, ?lead,
// [androidTitleTrailing]'s own doc comment explains why this
// isn't a straight replacement: the title keeps its natural
// width (`Flexible`, not `Expanded` - it still shrinks/
// ellipsizes if there's truly no room, but doesn't force the
// row that wide otherwise) and sits to the trailing content's
// left rather than being displaced by it.
if (androidTitleTrailing != null) ...[
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: NooText.title.copyWith(height: 1, color: colors.fg1),
),
),
Expanded(child: androidTitleTrailing!),
] else
Expanded( Expanded(
child: child: Text(
androidTitleReplacement ??
Text(
title, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,