Files
noo/.claude/context/styling.md
T
ayushyaandClaude Sonnet 5 b73f451fff Fix pull-to-refresh, header tint, sheet controls, and Shares tap
- Every tab's RefreshIndicator+CustomScrollView was missing an explicit
  AlwaysScrollableScrollPhysics, so pull-to-refresh silently couldn't be
  triggered once the list was short enough to fit the viewport (empty,
  or a single item) - the platform default physics only allows the
  overscroll drag RefreshIndicator listens for once content overflows.
- StickyHeaderDelegate (Files'/Photos'/Favorites' pinned controls row)
  painted its background from Theme.of(context).colorScheme.surface -
  Flutter's own Material 3 scheme, reseeded by the user's accent color
  choice - instead of context.nooColors.bg like the rest of the screen,
  showing as a stray colored cast behind the row.
- NooSegmentedControl gains onSurface: true wherever one sits inside a
  sheet (Files'/Photos' sort and type-filter controls) - it defaulted to
  the same surface fill as the sheet itself, so the control had no
  visible boundary showing it as a grouped unit.
- Shares tab: removed the per-row "..." overflow menu on every scope
  (With you/By you/Links); tapping a row now opens the full Share sheet
  for that item instead, fetched via the new
  FilesController.fetchItemAtPath (a share only carries enough metadata
  for its own row, not what the sheet's header needs).
- fetchShares() now reuses _shareFromJson instead of its own thinner
  inline parser, so url/token/permissions/expireDate are actually
  populated - a public-link share could never show "Copy link" before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:45:05 -04:00

16 KiB
Raw Blame History

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.seedColors is 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's DynamicColorBuilder wrapping the whole app in main.dart. When available and useDynamicColor is on, the OS-provided ColorScheme wins over the seed color — always thread both dynamicScheme and useDynamicColor through when adding a theme knob.
  • Font: Inter via google_fonts, applied through GoogleFonts.interTextTheme(...). In tests, set GoogleFonts.config.allowRuntimeFetching = false in setUpAll — without it, the font-fetch call to Google's CDN can stall pumpAndSettle indefinitely (see standards.md).
  • Cards: flat (elevation: 0), 20px rounded corners, surfaceContainerLow.
  • App bars: flat, not centered, surface background.
  • 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-2023 SliderTheme. Follow that pattern (a themed default + a comment) instead of overriding per-instance if you need the same behavior elsewhere.
  • Dark theme supports an amoled flag that flattens every surface tone to pure black — extend colorScheme.copyWith(...) there if a new surface role needs the same treatment, don't hardcode Colors.black at call sites.
  • Scrollbars are deliberately not shown — every scrollable list in the app is a plain ListView/CustomScrollView with no Scrollbar wrapper and no scrollbarTheme override (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 via context.nooColors (a NooColors ThemeExtension), plus NooText, NooSpace, NooRadii, NooSizes, NooMotion and nooDialogShadow. NooText styles set no color; callers add it with copyWith(color: ...). Flutter's TextStyle.height is 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.*) inside noo/, never Material Icons.*.
  • 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, with NooNavStyle.fromPlatform) for nav chrome, and iosStyle on 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:

  • NooDrawer can't set its own scrim. The host Scaffold needs drawerScrimColor: context.nooColors.scrim.
  • NooGroupedList draws dividers by showing line through 1px gaps, so each child must paint its own surface (NooSettingsRow and NooTabOrderRow do). Its collapsible/initiallyExpanded params (off by default) make label a tap target that shows/hides the card - SettingsSection is the only caller that opts in, for Settings' mobile sections.
  • NooSwipeAction only reveals its action. The user has to tap the block to trigger it; a full swipe never deletes.
  • Window chrome (macOS traffic lights, the Windows 40px title bar) isn't built yet. NooSidebar.windowControls is the slot for it.
  • Always read colors through context.nooColors, never Theme.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.dart shipped with this bug once already (a pinned controls-row header tinted by the accent instead of matching its screen's plain colors.bg).
  • NooSegmentedControl defaults to onSurface: false (colors.surface fill) - right when it's sitting directly on colors.bg (List/Grid toggle, Shares' scope switcher), but pass onSurface: true for one placed inside a sheet or dialog (already colors.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 RefreshIndicator needs physics: 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's CustomScrollView sets 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 from NooChip/NooSegmentedControl; still the sort/hidden/scope/type-filter/ view-mode row shared by Files and Offline (showStorageScope: false for 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 shared Breadcrumbs widget. Breadcrumbs is now also noo-styled (same tokens, private-widget-turned-shared) — it's used only by ShareUploadView/MoveCopyDestinationPicker, which is why it was safe to restyle directly instead of forking another FileBreadcrumbRow-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 plus settings_section.dart's SettingsSection/showSettingsPicker and settings_dialogs.dart's confirmRemoveAccount. 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/getIconColor are superseded by NooFileKind in rebuilt screens; ItemThumbnail is still reused as-is, fed into NooFileTile/NooFileRow/NooFileTableRow/NooGridCard's thumbnail slot. ShareUploadView/MoveCopyDestinationPicker don'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 plain NooFileTile(kind: NooFileKind.folder) covers every row.
  • DetailsSheet/ShareSheet (lib/widgets/details/details_sheet.dart, lib/widgets/share_sheet.dart) are rebuilt: showNooSheet/showNooDialog per NooLayout.isDesktop, with the header built from detailsFileTile (a NooFileTile keyed by NooFileKind.from) and detailsMetaLine - shared top-level helpers in details_sheet.dart so both sheets open on the same header, replacing the old DetailsHeader widget. Neither sheet uses showGradualBottomSheet's drag-to-resize any more: DetailsSheet swaps Info/Versions/Activity with a NooSegmentedControl (there's no tab-strip component in the noo kit) instead of a TabBar/TabBarView, and both sheets' content sits in one Column so showNooSheet's/ showNooDialog's own SingleChildScrollView handles 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 follow DESIGN_SYSTEM.md §4 via NooShareSection/ NooPersonAccessRow/NooPermissionPill; the permission pill and the link's permission/expiry chips are read-only display (no ItemOperations call 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. MoveCopyConflictSheet is rebuilt: showNooDialog/showNooSheet (per NooLayout.isDesktop), a NooGroupedList of file-tile rows with an inline NooSegmentedControl<ConflictChoice> once "Decide per item" is picked, and NooButtons for overwrite-all/keep-both/decide-per-item/confirm. Its show(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/bottomBarBlur and lib/widgets/floating_bottom_bar.dart/media_grid_tile.dart/ swipeable_item.dart/sync_status_badge.dart/selectable_thumbnail.dart are gone: the bottom bar is now the flat, non-blurred NooBottomBar (no opacity/blur knob - flat surfaces per the design system), grid tiles are NooGridCard, swipe actions are NooSwipeAction, and per-item sync status is NooFileRow/NooFileTableRow's built-in NooStatusIcon list instead of a corner badge.
  • SyncedHeaderScaffold — the pull-to-sync CustomScrollView header with the persistent sync-status chip and pull-to-refresh gesture/spinner. Every screen (including ShareUploadView/MoveCopyDestinationPicker, its last two users) has dropped it for a plain RefreshIndicator + CustomScrollView (device-sync status now shows per-row via NooStatusIcon/the Offline NooSummaryCard, not a shared header chip), so the SyncedHeaderScaffold class itself is now dead code - kept only because the same file's top-level formatBytes helper is still widely used (files_view.dart/favorites_view.dart/shell_common.dart/ widgets/details/*).
  • SegmentedIconGroup/ToggleIconButton and SortMenuButton — the Material sort/filter-chip pieces ShareUploadView/ MoveCopyDestinationPicker used 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 - Breadcrumbs is the only shared widget promoted to noo styling instead of removed, since Files' own FileBreadcrumbRow proved the same trail is still wanted elsewhere.
  • SeekBarPainter/SeekBarPreview — the four MediaProgressBarStyle presets (Default/Wavy/Slim/Squiggly) for the video player's seek bar, plus a perpetually-animated SeekBarPreview wrapper used by the Settings style picker so every preview always matches the real widget exactly (same painter, just fed demo progress/phase values). 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 + Icon at a fixed 22px, colored from colorScheme.onSurface unless overridden) rather than plain IconButtons — match this instead of adding a bare IconButton in 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 with TextPainter first and only switch to a scrolling Marquee when the text actually doesn't fit, rather than marqueeing unconditionally.
  • Icons: prefer Icons.*_rounded (matches the rest of the app) or material_symbols_icons where 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, not Colors.blue etc. (per-file-type icon tinting in files_view.dart's _getIconColor is the one deliberate exception, since those colors are content-identity cues, not theme).
  • Use colorScheme.surfaceContainer*/onSurfaceVariant tokens 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 via ColorFiltered(colorFilter: ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...) so it reads correctly in both light and dark mode. Never the full-color app_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-* in android/app/src/main/res); there is no generator step. assets/icon/app_icon_monochrome.png is deliberately a tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome layer), so it renders at a sensible size at 72-80px.