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
14 changed files with 140 additions and 113 deletions
Showing only changes of commit b73f451fff - Show all commits
@@ -330,7 +330,14 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
photo grid grouped by month. photo grid grouped by month.
- **Favorites:** a file list with a star as the trailing icon. - **Favorites:** a file list with a star as the trailing icon.
- **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta. - **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta.
- **Shares:** a segmented control (With you, By you, Links), then a list. The meta reads "Owner · Permission". - **Shares:** a segmented control (With you, By you, Links), then a list. The
meta reads "Owner · Permission". No per-row overflow menu on any of the
three scopes - tapping a row (file or folder) opens the full Share sheet
for that item (fetched fresh via `FilesController.fetchItemAtPath`, since
a share only carries enough metadata for its own row), the same sheet
Files/Photos open from their own Share action - that's already where
copying a link or removing access lives, so a second, row-local menu here
was redundant.
- **Recent:** a list grouped into Today, Yesterday and This week. The meta is the action plus the location. - **Recent:** a list grouped into Today, Yesterday and This week. The meta is the action plus the location.
- **Activity:** the feed grouped by day. Desktop limits it to 760px wide. - **Activity:** the feed grouped by day. Desktop limits it to 760px wide.
- **Trash:** a retention banner, then a list. Mobile rows get a restore icon; desktop rows get a tonal "Restore" pill. - **Trash:** a retention banner, then a list. Mobile rows get a restore icon; desktop rows get a tonal "Restore" pill.
+18
View File
@@ -96,6 +96,24 @@ Gotchas:
trigger it; a full swipe never deletes. trigger it; a full swipe never deletes.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
built yet. `NooSidebar.windowControls` is the slot for it. 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 ## Reusable chrome
+22 -3
View File
@@ -266,7 +266,8 @@ class FilesController extends ChangeNotifier
/// sort, cache policy) - hooked to `addAccountReadyListener` as well as /// sort, cache policy) - hooked to `addAccountReadyListener` as well as
/// activation so the Offline tab, which reads these same prefs, respects /// activation so the Offline tab, which reads these same prefs, respects
/// them even on a provisional/offline login where activation never fires. /// them even on a provisional/offline login where activation never fires.
Future<void> _restoreDisplayPrefs() => _prefsRestore ??= _doRestoreDisplayPrefs(); Future<void> _restoreDisplayPrefs() =>
_prefsRestore ??= _doRestoreDisplayPrefs();
/// Completes once this account's cache policy/display prefs have been /// Completes once this account's cache policy/display prefs have been
/// loaded from storage - lets `SyncStatusController` schedule background /// loaded from storage - lets `SyncStatusController` schedule background
@@ -334,7 +335,9 @@ class FilesController extends ChangeNotifier
defaultCacheIntervalMinutes; defaultCacheIntervalMinutes;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('[FilesController] Account-activation prefs restore failed: $e'); debugPrint(
'[FilesController] Account-activation prefs restore failed: $e',
);
} }
} }
} }
@@ -401,7 +404,9 @@ class FilesController extends ChangeNotifier
notifyListeners(); notifyListeners();
} }
debugPrint('[FilesController] Refreshing data for path: $_currentFolderPath'); debugPrint(
'[FilesController] Refreshing data for path: $_currentFolderPath',
);
try { try {
final items = await service.fetchDirectory(_currentFolderPath); final items = await service.fetchDirectory(_currentFolderPath);
@@ -549,6 +554,20 @@ class FilesController extends ChangeNotifier
return session.service?.fetchDirectory(path) ?? Future.value([]); return session.service?.fetchDirectory(path) ?? Future.value([]);
} }
/// Finds the single item at [path] by listing its parent folder and
/// matching on the exact path - there's no WebDAV call here for stat'ing
/// one path directly outside a directory PROPFIND. Used by the Shares tab
/// to open the full Share sheet for a [NextcloudShare], which only carries
/// enough metadata for its own row, not the size/dates `ShareSheet`'s
/// header needs.
Future<NextcloudItem?> fetchItemAtPath(String path) async {
final normalized = path.startsWith('/') ? path : '/$path';
final lastSlash = normalized.lastIndexOf('/');
final parent = lastSlash <= 0 ? '/' : normalized.substring(0, lastSlash);
final items = await fetchFolderListing(parent);
return items.where((i) => i.path == normalized).firstOrNull;
}
void invalidateCache() => _directoryCache.clear(); void invalidateCache() => _directoryCache.clear();
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced /// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
+8 -2
View File
@@ -73,9 +73,12 @@ class ActivityView extends StatelessWidget {
? act.subject ? act.subject
: null, : null,
time: DateFormat.jm().format(act.timestamp), time: DateFormat.jm().format(act.timestamp),
avatarColor: NooPhotoTile.paletteColor(act.author.hashCode), avatarColor: NooPhotoTile.paletteColor(
act.author.hashCode,
),
currentUser: currentUser:
act.author.toLowerCase() == session.username.toLowerCase(), act.author.toLowerCase() ==
session.username.toLowerCase(),
), ),
), ),
], ],
@@ -94,6 +97,9 @@ class ActivityView extends StatelessWidget {
final scrollView = CustomScrollView( final scrollView = CustomScrollView(
controller: scrollController, controller: scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item feed.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
); );
+3
View File
@@ -654,6 +654,9 @@ class _FavoritesViewState extends State<FavoritesView> {
onRefresh: favoritesController.fetchAll, onRefresh: favoritesController.fetchAll,
child: CustomScrollView( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
+5
View File
@@ -838,6 +838,11 @@ class _FilesViewState extends State<FilesView> {
}, },
child: CustomScrollView( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// Pull-to-refresh needs a scroll physics that allows dragging
// past the edge even when content doesn't fill the viewport -
// an empty or single-item list otherwise can't be pulled at all
// under the platform default physics.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
@@ -359,6 +359,10 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
onRefresh: () => _fetch(_currentPath), onRefresh: () => _fetch(_currentPath),
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
// See files_view.dart's identical fix - without this, pull-
// to-refresh can't be triggered on an empty or single-folder
// listing.
physics: const AlwaysScrollableScrollPhysics(),
slivers: slivers, slivers: slivers,
), ),
), ),
+5
View File
@@ -364,6 +364,9 @@ class _PhotosViewState extends State<PhotosView> {
onRefresh: photosController.fetchAllMedia, onRefresh: photosController.fetchAllMedia,
child: CustomScrollView( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
@@ -432,6 +435,7 @@ class _PhotosViewState extends State<PhotosView> {
children: [ children: [
NooSegmentedControl<bool>( NooSegmentedControl<bool>(
fill: true, fill: true,
onSurface: true,
value: photos.sortAscending, value: photos.sortAscending,
onChanged: (asc) { onChanged: (asc) {
if (asc != photos.sortAscending) photos.toggleSortOrder(); if (asc != photos.sortAscending) photos.toggleSortOrder();
@@ -501,6 +505,7 @@ class _PhotosViewState extends State<PhotosView> {
// the two screens' filter sheets look and behave the same. // the two screens' filter sheets look and behave the same.
NooSegmentedControl<_PhotoTypeFilter>( NooSegmentedControl<_PhotoTypeFilter>(
fill: true, fill: true,
onSurface: true,
labelOnlySelected: true, labelOnlySelected: true,
value: _typeFilter, value: _typeFilter,
onChanged: (filter) => setState(() { onChanged: (filter) => setState(() {
+10 -2
View File
@@ -85,6 +85,9 @@ class _RecentViewState extends State<RecentView> {
onRefresh: recent.fetchAll, onRefresh: recent.fetchAll,
child: CustomScrollView( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
@@ -101,7 +104,9 @@ class _RecentViewState extends State<RecentView> {
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final group = groups[index]; final group = groups[index];
return Padding( return Padding(
padding: EdgeInsets.only(bottom: index == groups.length - 1 ? 0 : 18), padding: EdgeInsets.only(
bottom: index == groups.length - 1 ? 0 : 18,
),
child: NooGroupedList( child: NooGroupedList(
label: group.label, label: group.label,
children: [ children: [
@@ -132,7 +137,10 @@ class _RecentViewState extends State<RecentView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TabGroupLabel(group.label), TabGroupLabel(group.label),
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'), const NooFileTableHeader(
col2Label: 'Modified',
col3Label: 'Location',
),
for (final item in group.items) _buildDesktopRow(context, item), for (final item in group.items) _buildDesktopRow(context, item),
], ],
), ),
+4
View File
@@ -278,6 +278,10 @@ class _ShareUploadViewState extends State<ShareUploadView> {
onRefresh: files.refreshData, onRefresh: files.refreshData,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-folder
// listing.
physics: const AlwaysScrollableScrollPhysics(),
slivers: slivers, slivers: slivers,
), ),
), ),
+27 -101
View File
@@ -1,21 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import '../models/nextcloud_share.dart'; import '../models/nextcloud_share.dart';
import '../providers/files_controller.dart'; import '../providers/files_controller.dart';
import '../providers/settings_controller.dart';
import '../providers/shares_controller.dart'; import '../providers/shares_controller.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import '../widgets/noo/core/noo_segmented_control.dart'; import '../widgets/noo/core/noo_segmented_control.dart';
import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart'; import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_settings_row.dart';
import '../widgets/noo/noo_layout.dart'; import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/tabs/tab_state_slivers.dart'; import '../widgets/tabs/tab_state_slivers.dart';
/// Which slice of [SharesController.shares] is on screen - `With you` maps /// Which slice of [SharesController.shares] is on screen - `With you` maps
@@ -136,6 +131,9 @@ class _SharesViewState extends State<SharesView> {
onRefresh: sharesController.fetchAll, onRefresh: sharesController.fetchAll,
child: CustomScrollView( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
@@ -197,9 +195,8 @@ class _SharesViewState extends State<SharesView> {
meta: meta:
'${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}', '${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
iosStyle: NooLayout.iosStyle(context), iosStyle: NooLayout.iosStyle(context),
onTap: share.isFolder ? () => _openFolder(context, share) : null, onTap: () => _openShareSheet(context, share),
trailing: Icon(_shareTypeIcon(share.shareType), size: 16), trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
onMore: () => _showShareActions(context, share),
); );
final isFirst = index == 0; final isFirst = index == 0;
final isLast = index == shares.length - 1; final isLast = index == shares.length - 1;
@@ -247,112 +244,41 @@ class _SharesViewState extends State<SharesView> {
name: share.name, name: share.name,
col2: share.ownerDisplayName, col2: share.ownerDisplayName,
col3: _permissionLabel(share.permissions), col3: _permissionLabel(share.permissions),
onTap: share.isFolder ? () => _openFolder(context, share) : null, onTap: () => _openShareSheet(context, share),
onMore: () => _showShareActions(context, share),
); );
}, childCount: shares.length + 1), }, childCount: shares.length + 1),
), ),
); );
} }
/// Only folders are safely tappable - unlike Recent/Favorites, a share /// Every row's whole-row tap - a share only carries enough metadata for
/// doesn't carry the size/mime-type `FileViewerScreen` needs, so opening /// its own row, not the size/dates the Share sheet's header needs, so
/// a file share here would have to synthesize that data instead of /// this fetches the real item first (see `FilesController.fetchItemAtPath`)
/// reading it, same as the original screen (no tap action at all). /// rather than opening the sheet straight from [share]. Reused by every
void _openFolder(BuildContext context, NextcloudShare share) { /// scope (With you/By you/Links) - the Share sheet is the one place that
context.read<FilesController>().navigateToAbsoluteFolder(share.path); /// already knows how to copy a link or remove access, so there's no
context.read<SettingsController>().requestTab(AppTab.files); /// separate overflow menu here any more.
} Future<void> _openShareSheet(
/// The row/table overflow menu - used to jump straight to the unshare
/// confirmation with no menu at all, so a link share had no way to copy
/// its own URL short of opening it and finding "Copy link" inside the
/// full Share sheet. A link share gets that shortcut here too now;
/// everything else just gets the one remove/unshare action.
void _showShareActions(BuildContext context, NextcloudShare share) {
final isLink = share.shareType == ShareType.publicLink && share.url != null;
showNooSheet(
context,
children: [
NooGroupedList(
children: [
if (isLink)
NooSettingsRow(
icon: LucideIcons.copy,
label: const Text('Copy link'),
onTap: () {
Navigator.pop(context);
_copyShareLink(context, share);
},
),
NooSettingsRow(
icon: LucideIcons.trash2,
label: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'),
danger: true,
onTap: () {
Navigator.pop(context);
_confirmUnshare(context, share);
},
),
],
),
],
);
}
void _copyShareLink(BuildContext context, NextcloudShare share) {
Clipboard.setData(ClipboardData(text: share.url!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link copied'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _confirmUnshare(
BuildContext context, BuildContext context,
NextcloudShare share, NextcloudShare share,
) async { ) async {
final confirmed = await showDialog<bool>( final files = context.read<FilesController>();
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(share.sharedWithMe ? 'Remove share' : 'Unshare'),
content: Text(
share.sharedWithMe
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
: 'Stop sharing "${share.name}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Remove'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final success = await context.read<SharesController>().deleteShare(share); final item = await files.fetchItemAtPath(share.path);
if (!context.mounted) return;
if (item == null) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text('Could not find this item - it may have moved'),
success
? 'Removed share for ${share.name}'
: 'Failed to remove share for ${share.name}',
),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
return;
}
await ShareSheet.show(context, item);
// The sheet manages shares through `ItemOperations`, not this tab's own
// `SharesController` (see its `deleteShare`'s doc comment) - refetch so
// a share added/removed from inside it doesn't leave this list stale.
if (context.mounted) context.read<SharesController>().fetchAll();
} }
} }
+3
View File
@@ -100,6 +100,9 @@ class _TrashViewState extends State<TrashView> {
onRefresh: trashController.fetchAll, onRefresh: trashController.fetchAll,
child: CustomScrollView( child: CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers, slivers: contentSlivers,
), ),
), ),
+2
View File
@@ -109,6 +109,7 @@ class FilesControlsRow extends StatelessWidget {
children: [ children: [
NooSegmentedControl<bool>( NooSegmentedControl<bool>(
fill: true, fill: true,
onSurface: true,
value: files.sortAscendingFor(folderPath), value: files.sortAscendingFor(folderPath),
onChanged: (asc) { onChanged: (asc) {
if (asc != files.sortAscendingFor(folderPath)) { if (asc != files.sortAscendingFor(folderPath)) {
@@ -172,6 +173,7 @@ class FilesControlsRow extends StatelessWidget {
// the two screens' filter sheets look and behave the same. // the two screens' filter sheets look and behave the same.
NooSegmentedControl<FilesTypeFilter>( NooSegmentedControl<FilesTypeFilter>(
fill: true, fill: true,
onSurface: true,
labelOnlySelected: true, labelOnlySelected: true,
value: files.filesTypeFilter, value: files.filesTypeFilter,
onChanged: files.setFilesTypeFilter, onChanged: files.setFilesTypeFilter,
+18 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/design_tokens.dart';
/// Wraps [child] as a sliver that pins to the top of the scroll view when /// Wraps [child] as a sliver that pins to the top of the scroll view when
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away /// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
@@ -24,7 +25,23 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
double shrinkOffset, double shrinkOffset,
bool overlapsContent, bool overlapsContent,
) { ) {
return Material(color: Theme.of(context).colorScheme.surface, child: child); // `context.nooColors.bg`, not `Theme.of(context).colorScheme.surface` -
// the latter is Flutter's own Material 3 scheme, seeded from the user's
// accent color choice (see AppTheme.light/dark), so it carried a faint
// hue of whatever accent is picked, and didn't match the plain
// `colors.bg` every one of these screens' own `ColoredBox` background
// uses. `bg`, not `surface`, so the sort/filter chips and List/Grid
// toggle riding on top of this (each already `colors.surface`-filled)
// still pop against it, the same as they do everywhere else in the app
// - matching `surface` here would make this pinned header the one place
// they'd flatten into their background instead. `surfaceTintColor:
// Colors.transparent` guards against the same M3 elevation-tint
// behavior even if this ever gets a non-zero elevation.
return Material(
color: context.nooColors.bg,
surfaceTintColor: Colors.transparent,
child: child,
);
} }
@override @override