Translucent fill over a backdrop blur, both platforms and both bar styles. Frosted bars draw over an extended body, so the shell's extendBody and the tab clearance now key off NooBottomBar.drawsBehindBody. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
26 KiB
Styling
Migration in progress: the target look is
design-system/DESIGN_SYSTEM.md — warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/Instrument
Sans, no gradients/shadows — built from the widgets/noo/ component kit
(see "Noo design-system components" below). Rebuilt so far: the app shell
(top/bottom bars, drawer, sidebar, toolbar), Files/Offline, Photos,
Favorites, Recent, Activity, Trash, Shares, Settings, the lock screen,
login, ShareUploadView, MoveCopyDestinationPicker,
MoveCopyConflictSheet, the shared Breadcrumbs widget, and the file
details/share bottom sheets/dialogs (DetailsSheet/ShareSheet). Still on
the pre-rework Material 3 theme documented in "Theme" and "Reusable
chrome" below: FileViewerScreen (the media viewer), SearchView, and
LoginWebviewView. Update this file to describe each area as it gets
reworked, rather than leaving stale Material 3 guidance next to a design
system that's already superseded it.
Theme
All theming goes through AppTheme
(AppTheme.light/AppTheme.dark) — don't set colors/fonts ad hoc in
widgets. Key points:
- Material 3, seed-color based.
AppTheme.seedColorsis the picker list users choose from;defaultNextcloudBlue(#0082C9) is the fallback. - Dynamic color (Android 12+ Material You / desktop accent color) is
supported via
package:dynamic_color'sDynamicColorBuilderwrapping the whole app inmain.dart. When available anduseDynamicColoris on, the OS-providedColorSchemewins over the seed color — always thread bothdynamicSchemeanduseDynamicColorthrough when adding a theme knob. - Font: Inter via
google_fonts, applied throughGoogleFonts.interTextTheme(...). Font files ship inassets/google_fonts/(Instrument Sans, Schibsted Grotesk) andmain()setsGoogleFonts.config.allowRuntimeFetching = false, so the app never contacts Google (the privacy policy on the website relies on this — keep it true; add new weights as bundled files). Tests set the same flag insetUpAll(seestandards.md). - Cards: flat (
elevation: 0), 20px rounded corners,surfaceContainerLow. - App bars: flat, not centered,
surfacebackground. - Two Flutter defaults are deliberately overridden app-wide rather than
per-widget, each with a comment explaining why in
app_theme.dart: Android predictive-back page transitions, and the non-2023SliderTheme. Follow that pattern (a themed default + a comment) instead of overriding per-instance if you need the same behavior elsewhere. - Dark theme supports an
amoledflag that flattens every surface tone to pure black — extendcolorScheme.copyWith(...)there if a new surface role needs the same treatment, don't hardcodeColors.blackat call sites. - Scrollbars are deliberately not shown — every scrollable list in the
app is a plain
ListView/CustomScrollViewwith noScrollbarwrapper and noscrollbarThemeoverride (tried once, explicitly reverted). Don't reintroduce one without being asked.
Noo design-system components
The rebuilt UI is assembled from lib/widgets/noo/, which implements
DESIGN_SYSTEM.md §2–3. Build new screens from these rather than from raw
Material widgets or the pre-rework chrome below.
- Tokens live in
design_tokens.dart: colors viacontext.nooColors(aNooColorsThemeExtension), plusNooText,NooSpace,NooRadii,NooSizes,NooMotionandnooDialogShadow.NooTextstyles set no color; callers add it withcopyWith(color: ...). Flutter'sTextStyle.heightis a multiple of font size, so the spec's CSS line-height maps to it directly (for example, 0.9 →height: 0.9). - Icons are Lucide (
lucide_icons_flutter,LucideIcons.*) insidenoo/, never MaterialIcons.*. - Components are data-agnostic. They take strings, icons, colors and
callbacks, not
NextcloudItem/AppTab, so the screen layer maps models onto them. - Platform differences are chosen with a flag rather than by reading the
platform inside the widget:
NooNavStyle(ios/android, withNooNavStyle.fromPlatform) for nav chrome, andiosStyleon rows and cards for the ellipsis vs. vertical-ellipsis overflow icon.
Catalog:
| Folder | Components |
|---|---|
core/ |
NooButton, NooFab, NooChip, NooSegmentedControl, NooToggle, NooSearchField, NooAvatar, NooBadge, NooProgressBar |
lists/ |
NooGroupedList, NooSettingsRow, NooTabOrderRow, NooBanner, NooInfoNote (tinted info callout; SettingsSection(notice:)), NooSummaryCard, NooSelectionBar |
files/ |
NooFileKind (spec §1.2 tiles; NooFileKind.from(name:, mimeType:, isDirectory:)), NooFileTile, NooStatusIcon/NooSyncStatus, NooFileRow (mobile 64px), NooFileTableHeader/NooFileTableRow (desktop), NooSwipeAction |
media/ |
NooGridCard, NooPhotoTile (video badge, selection), NooPhotoGroupHeader/NooPhotoGrid (sliver, or .box), NooActivityItem, NooStatCard |
nav/ |
NooBottomBar, NooTopBar (a PreferredSizeWidget) with NooTopBarButton/NooTopBarBack, NooDrawer with its Account/Storage/Item/Link parts, NooSidebar with NooSidebarItem/Divider/Account/Storage, NooToolbar (floating rounded card with margin; NooToolbar.outerHeight for appBar sizing) |
overlays/ |
showNooSheet, showNooDialog/NooDialog, NooOverlayHeader, NooTextField, and the share parts NooShareSection, NooPersonAccessRow, NooPermissionPill |
Gotchas:
NooDrawercan't set its own scrim. The hostScaffoldneedsdrawerScrimColor: context.nooColors.scrim.NooGroupedListdraws dividers by showinglinethrough 1px gaps, so each child must paint its own surface (NooSettingsRowandNooTabOrderRowdo). Itscollapsible/initiallyExpandedparams (off by default) makelabela tap target that shows/hides the card - no current caller opts in (Settings' mobile sections used to, when every section rendered inline in one long column; now each section is its own pushed screen - seeaccount_view.dart's doc comment - so there's nothing left to collapse). The params stay on the component itself since it's otherwise generic.NooSwipeActionreveals its action on a normal swipe (tap the block to trigger it) but also fires it directly if the drag goes far enough (past 1.8x the block's width) before release - a full swipe-through does delete in one gesture, it's not tap-only anymore.- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
built yet.
NooSidebar.windowControlsis the slot for it. - Always read colors through
context.nooColors, neverTheme.of(context).colorScheme- the latter is Flutter's own Material 3 scheme, reseeded by the user's accent color choice (AppTheme.light/.dark), so anything painted from it carries a faint hue of whatever accent is picked instead of the app's neutral palette.sticky_header_delegate.dartshipped with this bug once already (a pinned controls-row header tinted by the accent instead of matching its screen's plaincolors.bg). NooSegmentedControldefaults toonSurface: false(colors.surfacefill) - right when it's sitting directly oncolors.bg(List/Grid toggle, Shares' scope switcher), but passonSurface: truefor one placed inside a sheet or dialog (alreadycolors.surface), or its pill track blends invisibly into the sheet instead of reading as a grouped control (Files'/Photos' sort and type-filter sheets do this).- Any
RefreshIndicatorneedsphysics: const AlwaysScrollableScrollPhysics()on its scrollable child, or pull-to-refresh silently can't be triggered once the list is short enough to fit the viewport (empty, or one item) - every tab'sCustomScrollViewsets this explicitly for exactly that reason.
Reusable chrome
Rebuilt on the Noo design system (Files/Offline, Photos, Favorites,
Recent, Activity, Trash, Shares, Settings, the app shell, lock screen,
login, ShareUploadView, MoveCopyDestinationPicker, DetailsSheet and
ShareSheet): these no longer use the pieces below. Their own building
blocks are noted where they matter:
FilesControlsRow(lib/widgets/files_controls_row.dart) — now built fromNooChip/NooSegmentedControl; still the sort/hidden/scope/type-filter/ view-mode row shared by Files and Offline (showStorageScope: falsefor Offline), and reused as-is by Favorites.lib/widgets/files/file_breadcrumb_row.dart— the noo-styled breadcrumb trail Files uses in place of the sharedBreadcrumbswidget.Breadcrumbsis now also noo-styled (same tokens, private-widget-turned-shared) — it's used only byShareUploadView/MoveCopyDestinationPicker, which is why it was safe to restyle directly instead of forking anotherFileBreadcrumbRow-style copy; don't move Files back onto it.lib/widgets/tabs/(tab_state_slivers.dart,tab_day_groups.dart,tab_location.dart) — the loading/error/empty-state slivers and day/month grouping helpers shared by Recent/Activity/Trash/Shares.tab_state_slivers.dartalso hastopBarSliver, which every regular tab's view uses to plant its ownAppTopBarinstance as the first sliver in itsCustomScrollView(a thin wrapper around the framework'sSliverFloatingHeader) instead of the shell passing one shared instance toScaffold.appBar- see its doc comment andarchitecture.md's "Mobile" bullet for why.lib/widgets/settings/— Settings' 8 section widgets plussettings_section.dart'sSettingsSection/showSettingsPickerandsettings_dialogs.dart'sconfirmRemoveAccount.lib/widgets/shell/shell_common.dart— account/storage formatting,openSettings/openSearch,showAccountSwitcher,ShellAvatarButton,ShellSearchLauncher, shared by the mobile and desktop shell chrome.getItemIcon/getIconColor/ItemThumbnail—getItemIcon/getIconColorare superseded byNooFileKindin rebuilt screens;ItemThumbnailis still reused as-is, fed intoNooFileTile/NooFileRow/NooFileTableRow/NooGridCard'sthumbnailslot.ShareUploadView/MoveCopyDestinationPickerdon't use any of the three any more - both destination pickers now list folders only (see below), and a folder never gets a real thumbnail (only image/video do), so a plainNooFileTile(kind: NooFileKind.folder)covers every row.DetailsSheet/ShareSheet(lib/widgets/details/details_sheet.dart,lib/widgets/share_sheet.dart) are rebuilt:showNooSheet/showNooDialogperNooLayout.isDesktop, with the header built fromdetailsFileTile(aNooFileTilekeyed byNooFileKind.from) anddetailsMetaLine- shared top-level helpers indetails_sheet.dartso both sheets open on the same header, replacing the oldDetailsHeaderwidget. Neither sheet usesshowGradualBottomSheet's drag-to-resize any more:DetailsSheetswaps Info/Versions/Activity with aNooSegmentedControl(there's no tab-strip component in the noo kit) instead of aTabBar/TabBarView, and both sheets' content sits in oneColumnsoshowNooSheet's/showNooDialog's ownSingleChildScrollViewhandles overflow - a fixed page per tab no longer needs a resizable sheet to see the rest.showGradualBottomSheet(lib/widgets/gradual_bottom_sheet.dart) has no remaining callers as a result.ShareSheet's "Share with people"/"Share link"/"Send file directly" sections followDESIGN_SYSTEM.md§4 viaNooShareSection/NooPersonAccessRow/NooPermissionPill; the permission pill and the link's permission/expiry chips are read-only display (noItemOperationscall updates a share's permission/expiry/password/ hide-download yet) - promote those to real controls once that exists. Removing a person/group/email share is reachable by tapping their permission pill, which opens a small "Remove access" menu.MoveCopyConflictSheetis rebuilt:showNooDialog/showNooSheet(perNooLayout.isDesktop), aNooGroupedListof file-tile rows with an inlineNooSegmentedControl<ConflictChoice>once "Decide per item" is picked, andNooButtons for overwrite-all/keep-both/decide-per-item/confirm. Itsshow(BuildContext, List<MoveCopyConflict>)API is unchanged.FrostedGlassContainer— the blurred/translucent pill background for the media viewer's top/bottom bars and video transport controls (its other former user, the floating bottom nav bar, is gone - see below). Reuse this for any new floating overlay instead of building a new blur/shadow combo.- Upload FAB size:
SettingsController.fabStyle(FabStyle.auto/mini/expanded, Settings > Appearance > "Upload button") drivesNooFab.collapsedinmain.dart- auto collapses off Files/Photos, mini always icon-only, expanded always labelled. Phone/Android layout only. - PDF viewer (
media_pdf_preview.dart): pdfx's InteractiveViewer won't zoom out pastviewport.height / doc.height, so for short documents (<= 3 pages, measured up front) the full-sizePdfViewPinchis wrapped in aMediaQuerywith extra bottom padding (pdfx's child is aSafeArea) so the boundary height reaches the viewport and fit-width stays reachable after pinch-zooming, without shrinking the canvas.topInset(status bar- top bar height, 0 when the controls are hidden) animates the viewport below the overlaid top bar.
NooGroupedList.notice: optional widget (e.g.NooInfoNote) rendered under the label, above the card;SettingsSectionuses it for itsnoticeon phones.MediaDetailsPanel— the media viewer's bottom bar as a draggable panel (mobile/phone only; tablet-class still uses theDetailsSheetdialog). Dragging the bar, the Details button, or swiping up on swipeable media (rawListenerinfile_viewer_screen.dart) grows it upward so the action row rides on top of the details; tapping the media collapses it.SettingsController.bottomBarOpacity/bottomBarBlurandlib/widgets/floating_bottom_bar.dart/media_grid_tile.dart/swipeable_item.dart/sync_status_badge.dart/selectable_thumbnail.dartare gone: the bottom bar is now the flat, non-blurredNooBottomBar(no opacity/blur knob - flat surfaces per the design system), grid tiles areNooGridCard, swipe actions areNooSwipeAction, and per-item sync status isNooFileRow/NooFileTableRow's built-inNooStatusIconlist instead of a corner badge.NooBottomBarlater gained its own, unrelatedNooBottomBarStyle.floating(Settings → Appearance → "Bottom bar") - don't confuse the two: this one is still non-blurred, just inset with alineborder andnooDialogShadow(the app's one other shadow user - see that constant's doc comment) instead of edge to edge (no opacity/blur knob either).floatingignoresNooNavStyleand always uses the Android row (icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; only the attached bar still has a distinct iOS row. A separate, orthogonalSettingsController.bottomBarFrostedtoggle (Settings → Appearance → "Frosted glass bottom bar", off by default, both platforms and both bar styles) swaps the bar's solidsurfacefor a 0.72-alpha one over a 20-sigmaBackdropFilter(same sigma asFrostedGlassContainer) and dropsnooDialogShadow(it would show through the glass). A frosted bar only reads as glass with content behind it, so the shell usesNooBottomBar.drawsBehindBody(barStyle, frosted)forextendBody, andbottomBarClearance(and Files' own trailing sliver) reserve the bar's footprint for attached+frosted too, not just floating. The hostScaffoldneedsextendBody: truewhile it's active (main.dartalready wires this offSettingsController.bottomBarStyle), which also means every tab's own scrollable list has to reserve enough bottom padding to clear the bar - nothing does that automatically once the body draws behind it. UsebottomBarClearance(context)(tab_state_slivers.dart) for that rather than a flat100; it already accounts for both bar styles. Settings' separate "Search in bottom bar" toggle (SettingsController.searchInBottomBar) adds a never-highlighted Search entry to either bar style (NooBottomBar'ssearchDestination/onSearchTap) and lowersSettingsController.maxVisibleTabsby one - use that getter, notdefaultMaxVisibleTabsfrommodels/app_tab.dart, anywhere that needs the current cap on regular tabs.SettingsController.navMenuStyle(NooNavMenuStyle.drawer/avatarMenu, Settings → Appearance → "Navigation menu") picks what opens hidden tabs + Settings on mobile: the original hamburger-opens-AppDrawerpattern, or the avatar button opensshowAvatarMenuinstead. Wired throughAppTopBar→NooTopBar.onMenu(null inavatarMenumode - no menu icon renders at all, seeNooTopBar'sleadlogic) andShellAvatarButton's newonTap/labeloverrides (shell_common.dart) - a caller passing a customonTapmust also pass a matchinglabel, or the tooltip/semantics still say "Accounts" for a button that no longer opens the account switcher. InavatarMenumodemain.dartalso setsScaffold.drawerEnableOpenDragGesture: falseso the edge swipe can't open the drawer. The menu header has a chevron that expands an account section (other saved accounts to switch to, "Add Account", "Manage Accounts") above the hidden tabs/Settings.showAvatarMenuis this app's first use ofshowGeneralDialogdirectly (barrierColor: Colors.transparent+barrierDismissible: truefor a non-dimming click-outside-to-close menu, not a modal flow) - there's no existing anchored-popup primitive here (PopupMenuButton's own width doesn't stretch to a full content column), so don't reach forshowNooSheet/showNooDialogfor something shaped like this. It's positioned just past the status bar (SafeArea's own inset, not the top bar's full height on top of that) so it covers the top bar - including the tab title - rather than sitting below it, and its card carries two stackedboxShadows rather than justnooDialogShadowalone: that one shadow's blur is wide and soft enough to read as basically invisible on a small card over a dark theme's near-blackbg(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. 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 aContainercombinesborderwith its ownclipBehavior- 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-clippedContainershould be: noclipBehavioron the borderedContaineritself, and a 1px-insetClipRRect(radius reduced by that same 1px) around the filled, clipped content instead, so it never paints over the border. Also addedNooTopBar.androidTitleTrailing: Android has no large title to put a second search row under the way iOS'ssearch:slot does, so an inline search bar (AppTopBarpasses a plainShellSearchLauncher()when search isn't in the bottom bar) sits to the title's own right in that flexible slot -Flexible, notExpanded, 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— the pull-to-syncCustomScrollViewheader with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (includingShareUploadView/MoveCopyDestinationPicker, its last two users) has dropped it for a plainRefreshIndicator+CustomScrollView(device-sync status now shows per-row viaNooStatusIcon/the OfflineNooSummaryCard, not a shared header chip), so theSyncedHeaderScaffoldclass itself is now dead code - kept only because the same file's top-levelformatByteshelper is still widely used (files_view.dart/favorites_view.dart/shell_common.dart/widgets/details/*).SegmentedIconGroup/ToggleIconButtonandSortMenuButton— the Material sort/filter-chip piecesShareUploadView/MoveCopyDestinationPickerused to mirror Files' old controls row with. Both destination pickers dropped that whole row (they only ever browse folders, so sort/hidden/scope/type-filter/grid controls don't apply), so these two files are now dead code too -Breadcrumbsis the only shared widget promoted to noo styling instead of removed, since Files' ownFileBreadcrumbRowproved the same trail is still wanted elsewhere.SeekBarPainter/SeekBarPreview— the fourMediaProgressBarStylepresets (Default/Wavy/Slim/Squiggly) for the video player's seek bar, plus a perpetually-animatedSeekBarPreviewwrapper used by the Settings style picker so every preview always matches the real widget exactly (same painter, just fed demoprogress/phasevalues). Add new seek-bar presets here, not by forking the painter.- Chrome inside the media viewer (
file_viewer_screen.dart— the top bar's back button + filename, the bottom action bar, the video transport controls) all share one small hand-rolled icon-button pattern (_ActionIconButton:InkWell+Iconat a fixed 22px, colored fromcolorScheme.onSurfaceunless overridden) rather than plainIconButtons — match this instead of adding a bareIconButtonin that screen, since a default-styled one visibly stands out against the rest (this was a real bug: an unstyled back button read as "too large" next to everything else). - A title/label that might overflow a fixed-width chrome bar (e.g. the media
viewer's filename) should use
_MarqueeTitle-style logic — measure withTextPainterfirst and only switch to a scrollingMarqueewhen the text actually doesn't fit, rather than marqueeing unconditionally. - Icons: prefer
Icons.*_rounded(matches the rest of the app) ormaterial_symbols_iconswhere Material Symbols are already in use; avoid mixing in the sharp/outlined default set.
Conventions
- No hardcoded colors for anything themeable — pull from
Theme.of(context).colorScheme, notColors.blueetc. (per-file-type icon tinting infiles_view.dart's_getIconColoris the one deliberate exception, since those colors are content-identity cues, not theme). - Use
colorScheme.surfaceContainer*/onSurfaceVarianttokens for elevation/secondary text rather than manual opacity on black/white. - Anywhere the app shows its own icon in-app (splash, lock screen,
login screen) uses
assets/icon/app_icon_monochrome.png— a plain white silhouette on transparent, tinted viaColorFiltered(colorFilter: ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)so it reads correctly in both light and dark mode. Never the full-colorapp_icon.png/adaptive-icon assets for in-app UI — those are for the launcher icon only. The launcher icon is maintained by hand from an IconKitchen export (mipmap-*inandroid/app/src/main/res); there is no generator step.assets/icon/app_icon_monochrome.pngis deliberately a tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome layer), so it renders at a sensible size at 72-80px.
Text/markdown viewer
MediaTextPreview is an editable monospace TextField padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). NooPersonAccessRow.trailing replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in showNooSheet children must pop via a Builder context, not the caller's.
showNooSheet insets its body by the keyboard (viewInsets.bottom) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (.md/.markdown) in MediaTextPreview open rendered via flutter_markdown_plus (Markdown, styled from Noo tokens in _markdownStyle), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
ShareSheet: focusing the people search field does not scroll; once the user types, _revealPeopleSection animates the "Share with people" section (keyed by _peopleKey on the NooShareSection, not the inner column) to just below the sheet's top edge with a small gap.
Desktop/tablet layout notes
- The desktop branch in
main.dartis wrapped inSafeAreaso tablets keep system bars clear of the sidebar; the sidebar has no divider between pinned and hidden tabs. - Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the pushed category screens render the desktop card layout.
- Settings that don't apply in wide-tablet (Appearance nav options,
Swipe on a file) show a
NooInfoNoteviaSettingsSection.notice. - Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
Photos) take their column count from
NooLayout.gridColumns(min tile width, never below the phone count), so a tablet in portrait gets more columns rather than a few huge tiles.