diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 2e7ac7a..fa09d7b 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -245,11 +245,23 @@ blocks are noted where they matter: 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, tighter, more opaque contact shadow underneath it gives real elevation - in both themes. Also added `NooTopBar.androidTitleReplacement`: 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) replaces the - title in its flexible slot instead of sitting beside it as a bare icon. + in both themes. The card's border can go missing wherever an opaque row + sits against it too (every corner but the header's, which has no + full-bleed fill of its own) if a `Container` combines `border` with its + own `clipBehavior` - that 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 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) — the pull-to-sync `CustomScrollView` header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including diff --git a/lib/widgets/app_top_bar.dart b/lib/widgets/app_top_bar.dart index a3fa58e..8fa27a4 100644 --- a/lib/widgets/app_top_bar.dart +++ b/lib/widgets/app_top_bar.dart @@ -16,9 +16,9 @@ import 'shell/shell_common.dart'; /// title, then an inline search field that opens [SearchView]. /// - Android: `menu`, title, avatar actions. Upload has no top-bar icon /// here - it's the [NooFab] `MainShellView` shows instead. When search -/// isn't in the bottom bar, the title itself is replaced by an inline -/// search-field-styled launcher (`androidTitleReplacement`) rather than -/// a bare search icon sitting beside it. +/// isn't in the bottom bar, an inline search-field-styled launcher +/// (`androidTitleTrailing`) sits to the title's own right, rather than a +/// bare search icon over in `actions`. /// /// [style] is resolved by the caller (`NooLayout.navStyle`) rather than /// 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() : null, // Android has no large title to put a second search row under the - // way iOS does (`search:` below) - an inline bar replaces the title - // itself instead of sitting beside it as a plain icon action. - androidTitleReplacement: !_ios && _showSearch + // way iOS does (`search:` below) - an inline bar sits beside the + // title instead, rather than a plain search icon over in `actions`. + androidTitleTrailing: !_ios && _showSearch ? const ShellSearchLauncher() : null, actions: [ diff --git a/lib/widgets/avatar_menu.dart b/lib/widgets/avatar_menu.dart index 1f1f23a..2750243 100644 --- a/lib/widgets/avatar_menu.dart +++ b/lib/widgets/avatar_menu.dart @@ -75,9 +75,20 @@ class _AvatarMenuContent extends StatelessWidget { child: SizedBox( width: double.infinity, 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( - color: colors.surface, border: Border.all(color: colors.line), borderRadius: BorderRadius.circular(NooRadii.card), // `nooDialogShadow` alone is a wide, soft, fairly faint @@ -99,81 +110,88 @@ class _AvatarMenuContent extends StatelessWidget { nooDialogShadow, ], ), - child: Material( - color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Avatar on the right, name/host on the left - mirrors the - // top bar's own right-aligned avatar exactly, so this - // header reads as a continuation of the button that - // opened it rather than a disconnected card. - InkWell( - onTap: closeAndOpenSettings, - child: Padding( - padding: const EdgeInsets.all(NooSpace.md), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.bodyL.copyWith( - fontWeight: FontWeight.w600, - color: colors.fg1, - ), + child: Padding( + padding: const EdgeInsets.all(1), + child: ClipRRect( + borderRadius: BorderRadius.circular(NooRadii.card - 1), + child: Material( + color: colors.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Avatar on the right, name/host on the left - mirrors the + // top bar's own right-aligned avatar exactly, so this + // header reads as a continuation of the button that + // opened it rather than a disconnected card. + InkWell( + onTap: closeAndOpenSettings, + child: Padding( + padding: const EdgeInsets.all(NooSpace.md), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + session.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.bodyL.copyWith( + fontWeight: FontWeight.w600, + color: colors.fg1, + ), + ), + const SizedBox(height: 2), + Text( + serverHost(session.serverUrl), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.meta.copyWith( + color: colors.fg3, + ), + ), + ], ), - const SizedBox(height: 2), - Text( - serverHost(session.serverUrl), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.meta.copyWith( - color: colors.fg3, - ), - ), - ], - ), + ), + const SizedBox(width: 12), + NooAvatar( + initials: accountInitial(session.username), + current: true, + size: 40, + ), + ], ), - const SizedBox(width: 12), - NooAvatar( - initials: accountInitial(session.username), - current: true, - size: 40, - ), - ], + ), ), - ), - ), - if (hiddenTabs.isNotEmpty) ...[ - Divider(height: 1, color: colors.line), - for (final tab in hiddenTabs) + if (hiddenTabs.isNotEmpty) ...[ + Divider(height: 1, color: colors.line), + for (final tab in hiddenTabs) + NooSettingsRow( + icon: tab.icon, + label: Text(tab.label), + trailing: tab == AppTab.trash && trashCount > 0 + ? NooBadge( + tone: NooBadgeTone.accent, + child: Text('$trashCount'), + ) + : null, + onTap: () { + Navigator.pop(context); + settings.requestTab(tab); + }, + ), + ], + Divider(height: 1, color: colors.line), NooSettingsRow( - icon: tab.icon, - label: Text(tab.label), - trailing: tab == AppTab.trash && trashCount > 0 - ? NooBadge( - tone: NooBadgeTone.accent, - child: Text('$trashCount'), - ) - : null, - onTap: () { - Navigator.pop(context); - settings.requestTab(tab); - }, + icon: LucideIcons.settings, + label: const Text('Settings'), + onTap: closeAndOpenSettings, ), - ], - Divider(height: 1, color: colors.line), - NooSettingsRow( - icon: LucideIcons.settings, - label: const Text('Settings'), - onTap: closeAndOpenSettings, + ], ), - ], + ), ), ), ), diff --git a/lib/widgets/noo/nav/noo_top_bar.dart b/lib/widgets/noo/nav/noo_top_bar.dart index 830231a..b739141 100644 --- a/lib/widgets/noo/nav/noo_top_bar.dart +++ b/lib/widgets/noo/nav/noo_top_bar.dart @@ -56,14 +56,15 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { /// iOS only: the search field shown below the large title. final Widget? search; - /// Android only: replaces the plain title text with an arbitrary widget - /// (e.g. a search-field-styled launcher) in that same flexible middle - /// slot, between [leading]/the menu button and [actions]. iOS already - /// has a dedicated [search] slot below its large title for this; Android - /// has no large title to put a second row under, so a caller that wants - /// an inline search bar there replaces the title with one instead of - /// adding a row. - final Widget? androidTitleReplacement; + /// Android only: an extra widget (e.g. a search-field-styled launcher) in + /// the flexible middle slot, to the title's own right - [title] keeps + /// its natural (shrink-to-fit, ellipsized) width on the left, and this + /// takes whatever space is left rather than replacing it. iOS already has + /// a dedicated [search] slot below its large title for this; Android has + /// no large title to put a second row under, so a caller that wants an + /// inline search bar there puts it beside the title instead of adding a + /// row. + final Widget? androidTitleTrailing; /// Defaults to `bg` - the top bar sits on the screen background. final Color? backgroundColor; @@ -76,7 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { this.onMenu, this.actions = const [], this.search, - this.androidTitleReplacement, + this.androidTitleTrailing, this.backgroundColor, }); @@ -188,16 +189,31 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget { spacing: 4, children: [ ?lead, - Expanded( - child: - androidTitleReplacement ?? - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NooText.title.copyWith(height: 1, color: colors.fg1), - ), - ), + // [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( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NooText.title.copyWith(height: 1, color: colors.fg1), + ), + ), ...actions, ], ),