18 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(...). In tests, setGoogleFonts.config.allowRuntimeFetching = falseinsetUpAll— without it, the font-fetch call to Google's CDN can stallpumpAndSettleindefinitely (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, 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 |
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.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.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). 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.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.