Noo design system rebuild #1

Merged
ayushya merged 31 commits from noo-design-system-rebuild into main 2026-10-01 09:23:58 -04:00
4 changed files with 147 additions and 101 deletions
Showing only changes of commit fb4829d035 - Show all commits
+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: [
+89 -71
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,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,
), ),
], ),
), ),
), ),
), ),
+35 -19
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,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,
], ],
), ),