diff --git a/.claude/context/design-system/DESIGN_SYSTEM.md b/.claude/context/design-system/DESIGN_SYSTEM.md index 55f3de6..77b4eee 100644 --- a/.claude/context/design-system/DESIGN_SYSTEM.md +++ b/.claude/context/design-system/DESIGN_SYSTEM.md @@ -343,13 +343,16 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The - **Media viewer** (the full-screen photo/video viewer): the stage is `bg`, following the app's own theme rather than a fixed black - a black stage in light mode read as jarringly out of place. A translucent, blurred top - bar (back, filename, meta) and bottom bar float over the media in a fixed - dark panel regardless of theme (it needs to read over arbitrary photo/ - video brightness, not over `bg`); this is the one deliberate exception to - "no blur" in product UI, since it's chrome over photo/video content, not - over the app's own surfaces. Both bars' background extends edge-to-edge - behind the status bar/gesture area, with only their content padded clear - of it. Back is a plain arrow (`arrow-left`), not the iOS + bar (back, filename, meta) and bottom bar float over the media in a + `surface`-tinted panel (also theme-following, at a higher opacity than a + typical blur so it stays legible over arbitrary photo/video brightness + underneath - icons/text are `fg1`, matching); this is the one deliberate + exception to "no blur" in product UI, since it's chrome over photo/video + content, not over the app's own surfaces. A short edge gradient outside + each panel (toward `surface`, not a fixed black) extends that same + contrast a little past the panel's hard edge. Both bars' background + extends edge-to-edge behind the status bar/gesture area, with only their + content padded clear of it. Back is a plain arrow (`arrow-left`), not the iOS chevron+label pushed-screen pattern - platform split still to do. The bottom bar holds every action in one row (share, favorite, open externally, download, delete, details) on every platform; don't add a @@ -369,10 +372,13 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The - **Upload / Move / Copy destination picker:** a pushed screen (outside the tab shell, so it carries its own complete top bar) titled "Upload to" / "Move to" / "Copy to". Back arrow (Android) or "Cancel" text (iOS) leading, - no trailing action. Breadcrumb row, then a folder-only list (no files: this - screen only browses folders) using the standard file row/tile at the - folder kind. Bottom bar: a meta line ("Moving 3 items") above a full-width - 52px primary CTA ("Upload here" / "Move here" / "Copy here"). + no trailing action. The same `FilesControlsRow` (sort chip, filter chip, + List/Grid toggle right-anchored) Files itself uses, then a breadcrumb row, + then a folder-only list/grid (no files: this screen only browses folders) + using the standard file row/tile/grid-card at the folder kind. Bottom bar: + a centered meta line ("Moving 3 items") above a full-width 52px primary + CTA ("Upload here" / "Move here" / "Copy here") - a count, never a + filename, even for a single item. --- diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 66ec85b..65f5f16 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -379,9 +379,9 @@ class _FileViewerScreenState extends State { // The stage follows the app's own theme (`bg`) rather than being pinned // to black - a black stage in light mode read as jarringly out of place // next to the rest of the light-themed app. The floating top/action - // bars stay a fixed dark frosted panel regardless (see - // FrostedGlassContainer's doc comment) since they need to read over - // arbitrary photo/video brightness, not over this background. + // bars now follow the theme too (FrostedGlassContainer's own `surface` + // default), at a higher blur opacity to stay legible over arbitrary + // photo/video brightness underneath. final stageColor = colors.bg; return Scaffold( @@ -413,6 +413,10 @@ class _FileViewerScreenState extends State { // gradient fade, not `FrostedGlassContainer`'s blur - just // enough to keep the back button/title legible over bright // media without another blurred layer stacked on the bar itself. + // Fades toward `surface` (the same tint the panel itself uses), + // not a fixed black - a black scrim would fight the panel's own + // theme-matched icons/text (dark in light theme) instead of + // extending its contrast strategy past the panel's hard edge. if (_isSwipeable) IgnorePointer( child: AnimatedOpacity( @@ -420,11 +424,14 @@ class _FileViewerScreenState extends State { opacity: _controlsVisible ? 1 : 0, child: Container( height: 150, - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x8C000000), Colors.transparent], + colors: [ + colors.surface.withValues(alpha: 0.7), + Colors.transparent, + ], ), ), ), @@ -449,14 +456,10 @@ class _FileViewerScreenState extends State { vertical: 6, ), child: IconTheme.merge( - // Fixed white, not `colors.fg1`: this bar always - // sits on the dark translucent panel over the media - // stage (see `FrostedGlassContainer`'s doc comment), - // regardless of the app's light/dark theme. - data: const IconThemeData( - color: Colors.white, - size: 24, - ), + // `fg1`, matching FrostedGlassContainer's own + // theme-following panel underneath - both used to + // be a fixed white-on-black regardless of theme. + data: IconThemeData(color: colors.fg1, size: 24), child: Row( children: [ NooTopBarButton( @@ -469,7 +472,7 @@ class _FileViewerScreenState extends State { child: MarqueeTitle( text: _currentItem.name, style: NooText.label.copyWith( - color: Colors.white, + color: colors.fg1, ), ), ), @@ -491,11 +494,14 @@ class _FileViewerScreenState extends State { opacity: _controlsVisible ? 1 : 0, child: Container( height: 260, - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [Color(0x99000000), Colors.transparent], + colors: [ + colors.surface.withValues(alpha: 0.75), + Colors.transparent, + ], ), ), ), diff --git a/lib/views/move_copy_destination_picker.dart b/lib/views/move_copy_destination_picker.dart index fab4d22..ecdc279 100644 --- a/lib/views/move_copy_destination_picker.dart +++ b/lib/views/move_copy_destination_picker.dart @@ -7,12 +7,14 @@ import '../providers/files_controller.dart'; import '../providers/item_operations.dart'; import '../theme/design_tokens.dart'; import '../widgets/breadcrumbs.dart'; +import '../widgets/files_controls_row.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/move_copy_conflict_sheet.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/noo_layout.dart'; @@ -86,9 +88,7 @@ class _MoveCopyDestinationPickerState extends State { Future _fetch(String path) async { setState(() => _isLoading = true); - final raw = await context.read().fetchFolderListing( - path, - ); + final raw = await context.read().fetchFolderListing(path); if (!mounted) return; setState(() { _rawItems = raw; @@ -212,6 +212,19 @@ class _MoveCopyDestinationPickerState extends State { ); } + Widget _buildGridCard(NextcloudItem item) { + final colors = context.nooColors; + return NooGridCard( + name: item.name, + meta: 'Folder', + placeholderColor: colors.accentSoft, + icon: LucideIcons.folder, + iconColor: colors.accentText, + thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, + onTap: () => _navigateToFolder(item.path), + ); + } + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -231,16 +244,34 @@ class _MoveCopyDestinationPickerState extends State { final slivers = [ SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), - child: hasBreadcrumbs - ? SizedBox( + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.md, + gutter, + NooSpace.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 44, + child: FilesControlsRow( + folderPath: _currentPath, + showStorageScope: false, + ), + ), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( height: 32, child: Breadcrumbs( pathStack: _pathStack, onTap: _navigateToPathIndex, ), - ) - : const SizedBox.shrink(), + ), + ], + ], + ), ), ), if (_isLoading) @@ -251,6 +282,22 @@ class _MoveCopyDestinationPickerState extends State { icon: LucideIcons.folder, message: 'No folders here', ) + else if (files.isGridView) + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: gutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: desktop ? 5 : 2, + childAspectRatio: desktop ? 1.05 : 0.92, + crossAxisSpacing: desktop ? 16 : 10, + mainAxisSpacing: desktop ? 16 : 10, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildGridCard(folders[index]), + childCount: folders.length, + ), + ), + ) else if (desktop) SliverPadding( padding: EdgeInsets.symmetric(horizontal: gutter), @@ -266,7 +313,8 @@ class _MoveCopyDestinationPickerState extends State { padding: EdgeInsets.symmetric(horizontal: gutter), sliver: SliverList( delegate: SliverChildBuilderDelegate( - (context, index) => _buildRow(folders[index], index, folders.length), + (context, index) => + _buildRow(folders[index], index, folders.length), childCount: folders.length, ), ), @@ -312,7 +360,10 @@ class _MoveCopyDestinationPickerState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: () => _fetch(_currentPath), - child: CustomScrollView(controller: _scrollController, slivers: slivers), + child: CustomScrollView( + controller: _scrollController, + slivers: slivers, + ), ), ), bottomNavigationBar: DecoratedBox( @@ -331,13 +382,14 @@ class _MoveCopyDestinationPickerState extends State { ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( invalidDestination ? "Can't ${widget.copy ? 'copy' : 'move'} a folder into " "itself or one of its own subfolders" : '$verb $itemCount item${itemCount == 1 ? '' : 's'}', + textAlign: TextAlign.center, style: NooText.meta.copyWith( color: invalidDestination ? colors.danger : colors.fg3, ), @@ -350,7 +402,9 @@ class _MoveCopyDestinationPickerState extends State { disabled: invalidDestination || _isSubmitting, icon: _isSubmitting ? null - : (widget.copy ? LucideIcons.copy : LucideIcons.folderInput), + : (widget.copy + ? LucideIcons.copy + : LucideIcons.folderInput), onTap: invalidDestination || _isSubmitting ? null : () => _confirm(ops), diff --git a/lib/views/share_upload_view.dart b/lib/views/share_upload_view.dart index 57ee2af..c3b1661 100644 --- a/lib/views/share_upload_view.dart +++ b/lib/views/share_upload_view.dart @@ -10,11 +10,12 @@ import '../services/share_intent_service.dart'; import '../services/upload_service.dart'; import '../theme/design_tokens.dart'; import '../widgets/breadcrumbs.dart'; -import '../widgets/marquee_title.dart'; +import '../widgets/files_controls_row.dart'; import '../widgets/noo/core/noo_button.dart'; import '../widgets/noo/files/noo_file_kind.dart'; import '../widgets/noo/files/noo_file_row.dart'; import '../widgets/noo/files/noo_file_table.dart'; +import '../widgets/noo/media/noo_grid_card.dart'; import '../widgets/noo/nav/noo_top_bar.dart'; import '../widgets/noo/nav/noo_toolbar.dart'; import '../widgets/noo/noo_layout.dart'; @@ -51,7 +52,9 @@ class _ShareUploadViewState extends State { // Shared files have no relationship to wherever the user was last // browsing, so start the destination picker fresh at the root. WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) context.read().navigateToAbsoluteFolder('/'); + if (mounted) { + context.read().navigateToAbsoluteFolder('/'); + } }); } @@ -94,7 +97,12 @@ class _ShareUploadViewState extends State { // Only folders are valid upload destinations, so the listing - unlike // Files' own - never shows plain files at all. - Widget _buildRow(BuildContext context, NextcloudItem item, int index, int count) { + Widget _buildRow( + BuildContext context, + NextcloudItem item, + int index, + int count, + ) { final colors = context.nooColors; final files = context.read(); final row = NooFileRow( @@ -130,6 +138,20 @@ class _ShareUploadViewState extends State { ); } + Widget _buildGridCard(BuildContext context, NextcloudItem item) { + final colors = context.nooColors; + final files = context.read(); + return NooGridCard( + name: item.name, + meta: 'Folder', + placeholderColor: colors.accentSoft, + icon: LucideIcons.folder, + iconColor: colors.accentText, + thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104, + onTap: () => files.navigateToFolder(item.path), + ); + } + @override Widget build(BuildContext context) { final colors = context.nooColors; @@ -147,16 +169,34 @@ class _ShareUploadViewState extends State { final slivers = [ SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(gutter, NooSpace.md, gutter, NooSpace.sm), - child: hasBreadcrumbs - ? SizedBox( + padding: EdgeInsets.fromLTRB( + gutter, + NooSpace.md, + gutter, + NooSpace.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 44, + child: FilesControlsRow( + folderPath: currentPath, + showStorageScope: false, + ), + ), + if (hasBreadcrumbs) ...[ + const SizedBox(height: 10), + SizedBox( height: 32, child: Breadcrumbs( pathStack: files.pathStack, onTap: (index) => files.navigateToPathIndex(index), ), - ) - : const SizedBox.shrink(), + ), + ], + ], + ), ), ), if (files.isLoading) @@ -167,6 +207,22 @@ class _ShareUploadViewState extends State { icon: LucideIcons.folder, message: 'No folders here', ) + else if (files.isGridView) + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: gutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: desktop ? 5 : 2, + childAspectRatio: desktop ? 1.05 : 0.92, + crossAxisSpacing: desktop ? 16 : 10, + mainAxisSpacing: desktop ? 16 : 10, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => _buildGridCard(context, folders[index]), + childCount: folders.length, + ), + ), + ) else if (desktop) SliverPadding( padding: EdgeInsets.symmetric(horizontal: gutter), @@ -223,12 +279,20 @@ class _ShareUploadViewState extends State { color: colors.accent, backgroundColor: colors.surface, onRefresh: files.refreshData, - child: CustomScrollView(controller: _scrollController, slivers: slivers), + child: CustomScrollView( + controller: _scrollController, + slivers: slivers, + ), ), ), // A flat surface bar with a top line, peeking up from the bottom edge - - // the uploading-file summary (marqueed if it doesn't fit on one line) - // sits directly above the destination button. + // a centered item-count meta line sits directly above the destination + // button (DESIGN_SYSTEM.md's "Upload / Move / Copy destination + // picker" recipe: "a meta line ... above a full-width ... primary + // CTA" - matches MoveCopyDestinationPicker's identical bar). A count, + // not a filename: this is the one-file case just as much as the + // many-files case, and a long filename has no good fixed-width + // treatment the way a short count always does. bottomNavigationBar: DecoratedBox( decoration: BoxDecoration( color: colors.surface, @@ -245,48 +309,12 @@ class _ShareUploadViewState extends State { ), child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Sized to the text itself (like a real chip) up to the - // row's available width - MarqueeTitle needs a concrete - // (not just loose) width to know whether/how far to scroll, - // so this measures the text once up front rather than - // leaving the chip unconstrained. - LayoutBuilder( - builder: (context, constraints) { - final uploadingText = widget.files.length == 1 - ? 'Uploading ${widget.files.first.name}' - : 'Uploading ${widget.files.length} files'; - final chipTextStyle = NooText.label.copyWith( - color: colors.fg2, - ); - const horizontalPadding = 28.0; - final painter = TextPainter( - text: TextSpan(text: uploadingText, style: chipTextStyle), - maxLines: 1, - textDirection: Directionality.of(context), - )..layout(maxWidth: double.infinity); - final chipWidth = (painter.width + horizontalPadding).clamp( - 0.0, - constraints.maxWidth, - ); - - return Container( - width: chipWidth, - padding: const EdgeInsets.symmetric( - horizontal: NooSpace.smd, - vertical: NooSpace.xs, - ), - decoration: BoxDecoration( - color: colors.surface2, - borderRadius: BorderRadius.circular(NooRadii.pill), - ), - child: SizedBox( - height: 20, - child: MarqueeTitle(text: uploadingText, style: chipTextStyle), - ), - ); - }, + Text( + 'Uploading ${widget.files.length} item${widget.files.length == 1 ? '' : 's'}', + textAlign: TextAlign.center, + style: NooText.meta.copyWith(color: colors.fg3), ), const SizedBox(height: NooSpace.sm), NooButton( diff --git a/lib/views/trash_view.dart b/lib/views/trash_view.dart index 666c8f9..2f919ce 100644 --- a/lib/views/trash_view.dart +++ b/lib/views/trash_view.dart @@ -60,8 +60,12 @@ class _TrashViewState extends State { sliver: SliverToBoxAdapter( child: NooBanner( actionLabel: trash.isEmpty ? null : 'Empty trash', - onAction: trash.isEmpty ? null : () => _confirmEmptyTrash(context, trash), - child: const Text('Deleted items are kept for 30 days, then removed automatically.'), + onAction: trash.isEmpty + ? null + : () => _confirmEmptyTrash(context, trash), + child: const Text( + 'Deleted items are kept for 30 days, then removed automatically.', + ), ), ), ), @@ -115,6 +119,10 @@ class _TrashViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = trash[index]; + // The overflow menu's only action here is "Delete forever" - a + // bare ellipsis obscures that behind an extra tap to discover it, + // so this row shows the delete icon directly instead of going + // through `NooFileRow.onMore`. final row = NooFileRow( kind: NooFileKind.from( name: item.name, @@ -124,19 +132,30 @@ class _TrashViewState extends State { name: item.name, meta: _meta(item), iosStyle: NooLayout.iosStyle(context), - trailing: SizedBox.square( - dimension: 40, - child: Semantics( - button: true, - label: 'Restore', - child: InkResponse( - onTap: () => _restore(context, item), - radius: 20, - child: const Icon(LucideIcons.rotateCcw), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + label: 'Restore', + child: InkResponse( + onTap: () => _restore(context, item), + radius: 20, + child: const Icon(LucideIcons.rotateCcw), + ), ), - ), + const SizedBox(width: NooSpace.xs), + Semantics( + button: true, + label: 'Delete forever', + child: InkResponse( + onTap: () => _confirmDeleteForever(context, item), + radius: 20, + child: Icon(LucideIcons.trash2, color: colors.danger), + ), + ), + ], ), - onMore: () => _confirmDeleteForever(context, item), ); final isFirst = index == 0; final isLast = index == trash.length - 1; @@ -186,7 +205,7 @@ class _TrashViewState extends State { col2: _deletedLabel(item), col3: item.originalLocation ?? 'Unknown location', onRestore: () => _restore(context, item), - onMore: () => _confirmDeleteForever(context, item), + onDelete: () => _confirmDeleteForever(context, item), ); }, childCount: trash.length + 1), ), @@ -196,8 +215,9 @@ class _TrashViewState extends State { String _meta(NextcloudItem item) => 'Deleted ${_deletedLabel(item)} ยท ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}'; - String _deletedLabel(NextcloudItem item) => - item.deletedAt != null ? DateFormat.yMMMd().format(item.deletedAt!) : 'recently'; + String _deletedLabel(NextcloudItem item) => item.deletedAt != null + ? DateFormat.yMMMd().format(item.deletedAt!) + : 'recently'; Future _restore(BuildContext context, NextcloudItem item) async { final messenger = ScaffoldMessenger.of(context); @@ -301,19 +321,23 @@ class _TrashViewState extends State { } /// Desktop trash row: like `NooFileTableRow`, but its 120px last column -/// holds a tonal "Restore" button plus the overflow button (permanent -/// delete) instead of status icons - `NooFileTableRow` has no slot for -/// screen-specific actions there. Column widths (180/160/120) are matched -/// by hand to `NooFileTableHeader`'s (private in `noo_file_table.dart`) so -/// this still lines up under it; promoting an optional `actions` slot onto -/// `NooFileTableRow` would let this fold back into the shared component. +/// holds a tonal "Restore" button plus a danger-tonal "Delete" button +/// (permanent delete) instead of status icons - `NooFileTableRow` has no +/// slot for screen-specific actions there. A labelled pill, not a bare +/// overflow icon: it's the row's only destructive action, not a menu of +/// several, and desktop already prefers labelled buttons to bare icons +/// (see `NooSelectionBar`'s own desktop actions). Column widths +/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in +/// `noo_file_table.dart`) so this still lines up under it; promoting an +/// optional `actions` slot onto `NooFileTableRow` would let this fold back +/// into the shared component. class _TrashDesktopRow extends StatelessWidget { final NooFileKind kind; final String name; final String col2; final String col3; final VoidCallback onRestore; - final VoidCallback onMore; + final VoidCallback onDelete; const _TrashDesktopRow({ required this.kind, @@ -321,7 +345,7 @@ class _TrashDesktopRow extends StatelessWidget { required this.col2, required this.col3, required this.onRestore, - required this.onMore, + required this.onDelete, }); @override @@ -358,11 +382,21 @@ class _TrashDesktopRow extends StatelessWidget { ), SizedBox( width: 180, - child: Text(col2, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), + child: Text( + col2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), ), SizedBox( width: 160, - child: Text(col3, maxLines: 1, overflow: TextOverflow.ellipsis, style: metaStyle), + child: Text( + col3, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), ), SizedBox( width: 120, @@ -379,7 +413,12 @@ class _TrashDesktopRow extends StatelessWidget { child: const Text('Restore'), ), const SizedBox(width: 4), - NooOverflowButton(size: 24, onTap: onMore), + NooButton( + variant: NooButtonVariant.danger, + size: NooButtonSize.xs, + onTap: onDelete, + child: const Text('Delete'), + ), ], ), ), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index 3d90c22..d47c5b3 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -14,7 +14,7 @@ import 'sort_menu_button.dart' show sortFieldLabel; String _typeFilterLabel(FilesTypeFilter filter) { switch (filter) { case FilesTypeFilter.all: - return 'All types'; + return 'Filters'; case FilesTypeFilter.filesOnly: return 'Files only'; case FilesTypeFilter.foldersOnly: @@ -47,7 +47,8 @@ class FilesControlsRow extends StatelessWidget { @override Widget build(BuildContext context) { final files = context.watch(); - final filtersActive = files.filesTypeFilter != FilesTypeFilter.all || + final filtersActive = + files.filesTypeFilter != FilesTypeFilter.all || files.showHiddenFiles || (showStorageScope && files.storageScope != StorageScope.cloud); @@ -114,8 +115,16 @@ class FilesControlsRow extends StatelessWidget { } }, options: const [ - NooSegmentOption(value: true, icon: LucideIcons.arrowUp, label: 'Ascending'), - NooSegmentOption(value: false, icon: LucideIcons.arrowDown, label: 'Descending'), + NooSegmentOption( + value: true, + icon: LucideIcons.arrowUp, + label: 'Ascending', + ), + NooSegmentOption( + value: false, + icon: LucideIcons.arrowDown, + label: 'Descending', + ), ], ), const SizedBox(height: 22), @@ -125,7 +134,11 @@ class FilesControlsRow extends StatelessWidget { NooSettingsRow( label: Text(sortFieldLabel(field)), trailing: field == files.sortFieldFor(folderPath) - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, onTap: () { files.setSortFieldFor(folderPath, field); @@ -159,23 +172,38 @@ class FilesControlsRow extends StatelessWidget { NooSettingsRow( label: const Text('Files and folders'), trailing: files.filesTypeFilter == FilesTypeFilter.all - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), ), NooSettingsRow( label: const Text('Files only'), trailing: files.filesTypeFilter == FilesTypeFilter.filesOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + onTap: () => + files.setFilesTypeFilter(FilesTypeFilter.filesOnly), ), NooSettingsRow( label: const Text('Folders only'), - trailing: files.filesTypeFilter == FilesTypeFilter.foldersOnly - ? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) + trailing: + files.filesTypeFilter == FilesTypeFilter.foldersOnly + ? Icon( + LucideIcons.check, + size: 18, + color: context.nooColors.accentText, + ) : null, - onTap: () => files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + onTap: () => + files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), ), ], ), diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart index 4015a9d..a67c8dd 100644 --- a/lib/widgets/frosted_glass_container.dart +++ b/lib/widgets/frosted_glass_container.dart @@ -1,15 +1,18 @@ import 'dart:ui'; import 'package:flutter/material.dart'; +import '../theme/design_tokens.dart'; /// The blurred/translucent chrome for the media viewer's overlay bars /// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video /// transport row) - the one deliberate exception to the design system's /// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome /// floats over photo/video content rather than over the app's own -/// surfaces. No shadow, no outline, no `ColorScheme` tint: a fixed dark -/// translucent fill (`color`) that reads correctly over any media, -/// regardless of the app's light/dark theme - matching the canvas at -/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4. +/// surfaces. No shadow, no outline: [color] defaults to the app's own +/// `surface` token (so this panel reads as light or dark to match the +/// active theme, like the rest of the media viewer, instead of a fixed +/// dark tone regardless of theme) at a higher-than-usual [opacity], since +/// a light tint needs denser coverage than a near-black one did to stay +/// legible over arbitrary photo/video brightness underneath. class FrostedGlassContainer extends StatelessWidget { final Widget child; final double borderRadius; @@ -23,30 +26,30 @@ class FrostedGlassContainer extends StatelessWidget { /// adjustable. final double opacity; - /// The tint under the blur. Defaults to the design's fixed dark tone - /// (`#141311`, the spec's `fg-1` dark value) - deliberately not a - /// `ColorScheme`/`NooColors` lookup, since this chrome always sits over a - /// black media stage, not over a themed surface. - final Color color; + /// The tint under the blur. Defaults to [NooColors.surface] (resolved at + /// build time, so it always matches the active theme) - pass an explicit + /// color only to override that, e.g. for a fixed-color panel elsewhere. + final Color? color; const FrostedGlassContainer({ super.key, required this.child, this.borderRadius = 0, this.blurSigma = 20, - this.opacity = 0.55, - this.color = const Color(0xFF141311), + this.opacity = 0.8, + this.color, }); @override Widget build(BuildContext context) { + final tint = color ?? context.nooColors.surface; return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), child: Container( decoration: BoxDecoration( - color: color.withValues(alpha: opacity), + color: tint.withValues(alpha: opacity), borderRadius: BorderRadius.circular(borderRadius), ), child: child, diff --git a/lib/widgets/noo/overlays/noo_sheet.dart b/lib/widgets/noo/overlays/noo_sheet.dart index f23c9eb..eb1eafa 100644 --- a/lib/widgets/noo/overlays/noo_sheet.dart +++ b/lib/widgets/noo/overlays/noo_sheet.dart @@ -38,15 +38,20 @@ Future showNooSheet( ); } -/// Renders [children] once, unconstrained, to measure their natural height -/// against the sheet's available height. Content that fits within -/// [_kNooSheetPeekFraction] just stays as that first (plain, content-sized) -/// render - the common case for a short menu/option list. Content taller -/// than that switches to a [DraggableScrollableSheet] opened at the peek -/// fraction instead, so it never jumps straight to (near) full height with -/// no room left to adjust. The one extra layout pass for tall content -/// happens within the sheet's own entrance animation, so it isn't visible -/// as a jump. +/// Caps content at [_kNooSheetPeekFraction] of the available height from the +/// very first frame - never a one-shot "measure, then decide" pass, since +/// some sheets (Details' Versions/Activity tabs) fetch their content +/// asynchronously and only reach their real (long) size well after that +/// first frame, which a one-shot measurement would miss entirely. Content +/// that never needs more than that cap just sizes to itself as a plain +/// scroll view, at whatever height that is - the common case for a short +/// menu/option list, and visually identical to before. [_needsPeek] flips +/// true - upgrading to a draggable [DraggableScrollableSheet], opened at the +/// exact same peek height so nothing visibly jumps - the moment a +/// [ScrollMetricsNotification] reports the content actually overflowing +/// that cap, however and whenever that happens (immediately for a long +/// static list, or later, once async content finishes loading and grows +/// past it). class _NooSheetBody extends StatefulWidget { final List children; @@ -57,44 +62,44 @@ class _NooSheetBody extends StatefulWidget { } class _NooSheetBodyState extends State<_NooSheetBody> { - final _contentKey = GlobalKey(); - bool _measured = false; bool _needsPeek = false; - void _measureAfterFrame(double availableHeight) { + void _handleOverflow(ScrollMetrics metrics) { + if (_needsPeek || metrics.maxScrollExtent <= 0) return; + // `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids + // a "setState during build" error. WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _measured) return; - final box = _contentKey.currentContext?.findRenderObject() as RenderBox?; - final contentHeight = box?.size.height ?? 0; - setState(() { - _measured = true; - _needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction; - }); + if (mounted && !_needsPeek) setState(() => _needsPeek = true); }); } - Widget _buildContent(ScrollController? scrollController) { + Widget _buildScrollable(ScrollController? scrollController) { final colors = context.nooColors; - return SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), - child: Column( - key: _measured ? null : _contentKey, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36, - height: 5, - decoration: BoxDecoration( - color: colors.surface3, - borderRadius: BorderRadius.circular(NooRadii.pill), + return NotificationListener( + onNotification: (notification) { + _handleOverflow(notification.metrics); + return false; + }, + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: colors.surface3, + borderRadius: BorderRadius.circular(NooRadii.pill), + ), ), - ), - for (final child in widget.children) ...[ - const SizedBox(height: 22), - child, + for (final child in widget.children) ...[ + const SizedBox(height: 22), + child, + ], ], - ], + ), ), ); } @@ -104,12 +109,13 @@ class _NooSheetBodyState extends State<_NooSheetBody> { return SafeArea( child: LayoutBuilder( builder: (context, constraints) { - if (!_measured) { - _measureAfterFrame(constraints.maxHeight); - return _buildContent(null); - } if (!_needsPeek) { - return _buildContent(null); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * _kNooSheetPeekFraction, + ), + child: _buildScrollable(null), + ); } return DraggableScrollableSheet( initialChildSize: _kNooSheetPeekFraction, @@ -129,7 +135,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> { } return false; }, - child: _buildContent(scrollController), + child: _buildScrollable(scrollController), ); }, ); diff --git a/lib/widgets/viewer/media_action_bar.dart b/lib/widgets/viewer/media_action_bar.dart index e5ae8bb..b12c9e2 100644 --- a/lib/widgets/viewer/media_action_bar.dart +++ b/lib/widgets/viewer/media_action_bar.dart @@ -40,11 +40,7 @@ class MediaActionBar extends StatelessWidget { @override Widget build(BuildContext context) { - // This panel always sits on a black media stage regardless of the - // app's own light/dark theme (see FrostedGlassContainer's doc - // comment) - fixed dark-palette values, not `context.nooColors`, so - // favorite/delete stay legible even when the ambient theme is light. - const dark = NooColors.dark; + final colors = context.nooColors; // The blurred/tinted background lives outside the SafeArea (not inside // it) so it extends all the way to the physical bottom edge, behind the @@ -59,7 +55,7 @@ class MediaActionBar extends StatelessWidget { children: [ if (transportControls != null) ...[ transportControls!, - Divider(height: 1, color: Colors.white.withValues(alpha: 0.14)), + Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)), ], Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), @@ -80,7 +76,7 @@ class MediaActionBar extends StatelessWidget { tooltip: isFavorite ? 'Remove from favorites' : 'Favorite', - color: isFavorite ? dark.accentText : null, + color: isFavorite ? colors.accentText : null, onTap: onFavorite, ), ViewerIconButton( @@ -98,7 +94,7 @@ class MediaActionBar extends StatelessWidget { ViewerIconButton( icon: LucideIcons.trash2, tooltip: 'Delete', - color: dark.danger, + color: colors.danger, onTap: isBusy ? null : onDelete, ), ViewerIconButton( diff --git a/lib/widgets/viewer/media_video_preview.dart b/lib/widgets/viewer/media_video_preview.dart index 51576ae..879bd97 100644 --- a/lib/widgets/viewer/media_video_preview.dart +++ b/lib/widgets/viewer/media_video_preview.dart @@ -161,7 +161,7 @@ class VideoTransportControls extends StatelessWidget { return AnimatedBuilder( animation: controller, builder: (context, _) { - const fg = Colors.white; + final fg = context.nooColors.fg1; return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Column( diff --git a/lib/widgets/viewer/viewer_icon_button.dart b/lib/widgets/viewer/viewer_icon_button.dart index 1505b4e..65cfcc0 100644 --- a/lib/widgets/viewer/viewer_icon_button.dart +++ b/lib/widgets/viewer/viewer_icon_button.dart @@ -1,22 +1,22 @@ import 'package:flutter/material.dart'; +import '../../theme/design_tokens.dart'; /// Icon-only action button for the media viewer's floating chrome (top bar, /// bottom action bar, video transport controls) - a fixed 22px icon in a -/// round hit box. Colored white by default, not from [NooColors]/ -/// [ColorScheme]: this chrome always sits on the dark translucent panel -/// over a black media stage (see `FrostedGlassContainer`'s doc comment), -/// regardless of the app's light/dark theme, so a theme-derived color would -/// go near-invisible in light mode. Kept as one small widget so every -/// floating control in this screen looks the same instead of drifting -/// apart across the three call sites that need one. +/// round hit box. Colored from [NooColors.fg1] by default, matching +/// [FrostedGlassContainer]'s own theme-following panel underneath it (both +/// used to be fixed white-on-black regardless of theme - see their git +/// history). Kept as one small widget so every floating control in this +/// screen looks the same instead of drifting apart across the three call +/// sites that need one. class ViewerIconButton extends StatelessWidget { final IconData icon; final String tooltip; final VoidCallback? onTap; - /// Overrides the default white (e.g. `danger` for delete, `accent-text` - /// for a favorited state). Ignored while [onTap] is null - a disabled - /// button always dims to faded white regardless of its normal color. + /// Overrides the default [NooColors.fg1] (e.g. `danger` for delete, + /// `accent-text` for a favorited state). Ignored while [onTap] is null - + /// a disabled button always dims regardless of its normal color. final Color? color; const ViewerIconButton({ @@ -29,9 +29,8 @@ class ViewerIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final fg = onTap == null - ? Colors.white.withValues(alpha: 0.4) - : (color ?? Colors.white); + final fg1 = context.nooColors.fg1; + final fg = onTap == null ? fg1.withValues(alpha: 0.4) : (color ?? fg1); return Tooltip( message: tooltip, diff --git a/test/widgets/noo/overlays_test.dart b/test/widgets/noo/overlays_test.dart index 222e75b..f0e4a53 100644 --- a/test/widgets/noo/overlays_test.dart +++ b/test/widgets/noo/overlays_test.dart @@ -18,6 +18,43 @@ Widget _launcher(void Function(BuildContext) open) => Builder( TextButton(onPressed: () => open(context), child: const Text('open')), ); +/// A short placeholder that swaps in a long list one microtask later - +/// standing in for a sheet tab that fetches its content on first build +/// (`_requested`, as `DetailsVersionsTab`/`DetailsActivityTab` do) rather +/// than having it all synchronously on the very first frame. +class _AsyncGrowingContent extends StatefulWidget { + const _AsyncGrowingContent(); + + @override + State<_AsyncGrowingContent> createState() => _AsyncGrowingContentState(); +} + +class _AsyncGrowingContentState extends State<_AsyncGrowingContent> { + bool _loaded = false; + + @override + void initState() { + super.initState(); + Future.delayed(Duration.zero, () { + if (mounted) setState(() => _loaded = true); + }); + } + + @override + Widget build(BuildContext context) { + if (!_loaded) { + return const SizedBox(height: 40, child: Center(child: Text('Loading'))); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 30; i++) + SizedBox(height: 40, child: Text('Row $i')), + ], + ); + } +} + Finder _dialogCard() => find.byWidgetPredicate( (w) => w is Container && @@ -136,6 +173,38 @@ void main() { expect(sheetHeight, lessThan(500)); expect(sheetHeight, greaterThan(150)); }); + + testNooWidgets( + 'content that grows after an async load still switches to peek', + (tester, theme, c) async { + // Mirrors DetailsVersionsTab/DetailsActivityTab: a small placeholder + // first, then a long list once a fetch "completes" - the sheet has + // to catch the overflow whenever it happens, not just on its first + // frame (that one-shot approach is what shipped the original bug). + await pumpNoo( + tester, + _launcher( + (ctx) => showNooSheet( + ctx, + children: [const _AsyncGrowingContent()], + ), + ), + theme: theme, + surfaceSize: const Size(400, 700), + ); + await tester.tap(find.text('open')); + await tester.pump(); + expect(find.byType(DraggableScrollableSheet), findsNothing); + + await tester.pumpAndSettle(); + expect(find.byType(DraggableScrollableSheet), findsOneWidget); + final sheetHeight = tester + .getSize(find.byType(DraggableScrollableSheet)) + .height; + expect(sheetHeight, lessThan(500)); + expect(sheetHeight, greaterThan(150)); + }, + ); }); group('NooDialog', () {