Fix avatar menu's missing border and put the tab title beside search
Build APK / build (push) Successful in 5m36s
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:
@@ -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
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,81 +110,88 @@ class _AvatarMenuContent extends StatelessWidget {
|
|||||||
nooDialogShadow,
|
nooDialogShadow,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Padding(
|
||||||
color: Colors.transparent,
|
padding: const EdgeInsets.all(1),
|
||||||
child: Column(
|
child: ClipRRect(
|
||||||
mainAxisSize: MainAxisSize.min,
|
borderRadius: BorderRadius.circular(NooRadii.card - 1),
|
||||||
children: [
|
child: Material(
|
||||||
// Avatar on the right, name/host on the left - mirrors the
|
color: colors.surface,
|
||||||
// top bar's own right-aligned avatar exactly, so this
|
child: Column(
|
||||||
// header reads as a continuation of the button that
|
mainAxisSize: MainAxisSize.min,
|
||||||
// opened it rather than a disconnected card.
|
children: [
|
||||||
InkWell(
|
// Avatar on the right, name/host on the left - mirrors the
|
||||||
onTap: closeAndOpenSettings,
|
// top bar's own right-aligned avatar exactly, so this
|
||||||
child: Padding(
|
// header reads as a continuation of the button that
|
||||||
padding: const EdgeInsets.all(NooSpace.md),
|
// opened it rather than a disconnected card.
|
||||||
child: Row(
|
InkWell(
|
||||||
children: [
|
onTap: closeAndOpenSettings,
|
||||||
Expanded(
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
session.username,
|
child: Column(
|
||||||
maxLines: 1,
|
crossAxisAlignment:
|
||||||
overflow: TextOverflow.ellipsis,
|
CrossAxisAlignment.start,
|
||||||
style: NooText.bodyL.copyWith(
|
children: [
|
||||||
fontWeight: FontWeight.w600,
|
Text(
|
||||||
color: colors.fg1,
|
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(
|
const SizedBox(width: 12),
|
||||||
serverHost(session.serverUrl),
|
NooAvatar(
|
||||||
maxLines: 1,
|
initials: accountInitial(session.username),
|
||||||
overflow: TextOverflow.ellipsis,
|
current: true,
|
||||||
style: NooText.meta.copyWith(
|
size: 40,
|
||||||
color: colors.fg3,
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
NooAvatar(
|
|
||||||
initials: accountInitial(session.username),
|
|
||||||
current: true,
|
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (hiddenTabs.isNotEmpty) ...[
|
||||||
),
|
Divider(height: 1, color: colors.line),
|
||||||
if (hiddenTabs.isNotEmpty) ...[
|
for (final tab in hiddenTabs)
|
||||||
Divider(height: 1, color: colors.line),
|
NooSettingsRow(
|
||||||
for (final tab in hiddenTabs)
|
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(
|
NooSettingsRow(
|
||||||
icon: tab.icon,
|
icon: LucideIcons.settings,
|
||||||
label: Text(tab.label),
|
label: const Text('Settings'),
|
||||||
trailing: tab == AppTab.trash && trashCount > 0
|
onTap: closeAndOpenSettings,
|
||||||
? NooBadge(
|
|
||||||
tone: NooBadgeTone.accent,
|
|
||||||
child: Text('$trashCount'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
settings.requestTab(tab);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Divider(height: 1, color: colors.line),
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.settings,
|
|
||||||
label: const Text('Settings'),
|
|
||||||
onTap: closeAndOpenSettings,
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,16 +189,31 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
spacing: 4,
|
spacing: 4,
|
||||||
children: [
|
children: [
|
||||||
?lead,
|
?lead,
|
||||||
Expanded(
|
// [androidTitleTrailing]'s own doc comment explains why this
|
||||||
child:
|
// isn't a straight replacement: the title keeps its natural
|
||||||
androidTitleReplacement ??
|
// width (`Flexible`, not `Expanded` - it still shrinks/
|
||||||
Text(
|
// ellipsizes if there's truly no room, but doesn't force the
|
||||||
title,
|
// row that wide otherwise) and sits to the trailing content's
|
||||||
maxLines: 1,
|
// left rather than being displaced by it.
|
||||||
overflow: TextOverflow.ellipsis,
|
if (androidTitleTrailing != null) ...[
|
||||||
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
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,
|
...actions,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user