From f78ceb7e8bbcc8761857914f4dce930ab34d82b0 Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 16 Sep 2026 16:28:51 -0400 Subject: [PATCH] Add files/folders/both filter and folder-navigation slide animation The Files controls bar now scrolls horizontally to fit a new segmented toggle (files & folders / files only / folders only), persisted per account like the other browsing prefs. Navigating into or out of a folder now slides the listing in (from the right when going deeper, from the left when going back), staggered slightly per row. This is a per-item animation rather than a real page transition, since folder navigation swaps ServerProvider's path stack in place rather than pushing Navigator routes - true OS-level predictive back would need that to change. Co-Authored-By: Claude Sonnet 5 --- lib/providers/server_provider.dart | 34 +++- lib/services/account_store.dart | 1 + lib/views/files_view.dart | 269 +++++++++++++++++++++-------- 3 files changed, 228 insertions(+), 76 deletions(-) diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 52a4cde..9fc2f20 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -28,6 +28,10 @@ enum StorageScope { cloud, external } /// in Settings. enum SwipeAction { none, favorite, delete, share } +/// The Files tab's files/folders/both filter — independent of and applied +/// after [StorageScope]/favorites/hidden filtering. +enum FilesTypeFilter { all, filesOnly, foldersOnly } + /// How aggressively the Files tab's folder listings (not thumbnails/file /// content - just the list of names/sizes/dates) are reused across /// navigation instead of refetched from the server every time. @@ -70,6 +74,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { static const _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only'; static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; static const _prefStorageScope = 'ui_storage_scope'; + static const _prefFilesTypeFilter = 'ui_files_type_filter'; static const _prefShowHiddenFiles = 'ui_show_hidden'; static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; static const _prefSortField = 'ui_sort_field'; @@ -141,6 +146,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { bool _showFavoritesOnlyFiles = false; bool _showFavoritesOnlyPhotos = false; StorageScope _storageScope = StorageScope.cloud; + FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all; bool _showHiddenFiles = false; bool _showHiddenPhotos = false; // Photos tab sort - a single global setting (Photos has no folder concept, @@ -309,6 +315,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { bool get showFavoritesOnlyFiles => _showFavoritesOnlyFiles; bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos; StorageScope get storageScope => _storageScope; + FilesTypeFilter get filesTypeFilter => _filesTypeFilter; bool get showHiddenFiles => _showHiddenFiles; bool get showHiddenPhotos => _showHiddenPhotos; FileSortField get photosSortField => _photosSortField; @@ -385,11 +392,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { } List get items { - final filtered = _applyCommonFilters( + var filtered = _applyCommonFilters( _items, showFavoritesOnly: _showFavoritesOnlyFiles, showHidden: _showHiddenFiles, ); + switch (_filesTypeFilter) { + case FilesTypeFilter.all: + break; + case FilesTypeFilter.filesOnly: + filtered = filtered.where((i) => !i.isFolder).toList(); + case FilesTypeFilter.foldersOnly: + filtered = filtered.where((i) => i.isFolder).toList(); + } final field = filesSortField; final folders = filtered.where((i) => i.isFolder).toList() @@ -565,6 +580,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _showFavoritesOnlyFiles = false; _showFavoritesOnlyPhotos = false; _storageScope = StorageScope.cloud; + _filesTypeFilter = FilesTypeFilter.all; _showHiddenFiles = false; _showHiddenPhotos = false; _photosSortField = FileSortField.name; @@ -588,6 +604,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { orElse: () => StorageScope.cloud, ); + final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter)); + _filesTypeFilter = FilesTypeFilter.values.firstWhere( + (f) => f.name == filesTypeFilterName, + orElse: () => FilesTypeFilter.all, + ); + _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; _showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; @@ -1202,6 +1224,16 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { ); } + void setFilesTypeFilter(FilesTypeFilter filter) { + if (_filesTypeFilter == filter) return; + _filesTypeFilter = filter; + notifyListeners(); + _persistAccountPref( + _prefFilesTypeFilter, + (p, key) => p.setString(key, filter.name), + ); + } + /// Only *enabling* hidden-files visibility is gated - hiding them again /// never exposes anything, so that direction is always allowed instantly. Future toggleShowHiddenFiles() async { diff --git a/lib/services/account_store.dart b/lib/services/account_store.dart index e65d4e8..abe8a4d 100644 --- a/lib/services/account_store.dart +++ b/lib/services/account_store.dart @@ -35,6 +35,7 @@ class AccountStore { 'ui_sort_field', 'ui_sort_ascending', 'ui_folder_sort', + 'ui_files_type_filter', 'ui_cache_policy', 'ui_cache_interval_minutes', ]; diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index b333f00..ca31e8d 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -163,8 +163,72 @@ class _ItemThumbnail extends StatelessWidget { } } +/// Slides+fades its child in on first build. Give it a [Key] that changes +/// whenever the folder changes (folder path + item id) so Flutter discards +/// and remounts the Element instead of just updating it in place - that's +/// what makes the whole visible list replay the animation on every folder +/// navigation, not just newly-appearing rows. +class _FolderEnterAnimation extends StatefulWidget { + final Widget child; + final bool fromRight; + final int index; + + const _FolderEnterAnimation({ + super.key, + required this.child, + required this.fromRight, + required this.index, + }); + + @override + State<_FolderEnterAnimation> createState() => _FolderEnterAnimationState(); +} + +class _FolderEnterAnimationState extends State<_FolderEnterAnimation> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + late final Animation _offset; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + ); + _opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut); + _offset = Tween( + begin: Offset(widget.fromRight ? 0.12 : -0.12, 0), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); + // Staggered slightly by position so the list reads as sliding in as a + // group rather than every row popping in simultaneously; capped so a + // long list doesn't visibly trickle in for seconds. + final delay = Duration(milliseconds: (widget.index * 12).clamp(0, 150)); + Future.delayed(delay, () { + if (mounted) _controller.forward(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: SlideTransition(position: _offset, child: widget.child), + ); + } +} + class _FilesViewState extends State { final Set _selectedIds = {}; + int _lastPathDepth = 1; bool get _isSelecting => _selectedIds.isNotEmpty; @@ -331,6 +395,10 @@ class _FilesViewState extends State { final colorScheme = theme.colorScheme; final provider = context.watch(); + final pathDepth = provider.pathStack.length; + final navigatingDeeper = pathDepth > _lastPathDepth; + _lastPathDepth = pathDepth; + final hasBreadcrumbs = provider.pathStack.length > 1; final selectedItems = provider.items .where((i) => _selectedIds.contains(i.id)) @@ -346,81 +414,122 @@ class _FilesViewState extends State { children: [ SizedBox( height: 44, - child: Row( - children: [ - IconButton( - icon: Icon( - provider.filesSortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + IconButton( + icon: Icon( + provider.filesSortAscending + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 20, + ), + visualDensity: VisualDensity.compact, + tooltip: provider.filesSortAscending + ? 'Ascending' + : 'Descending', + onPressed: provider.toggleFilesSortOrder, ), - visualDensity: VisualDensity.compact, - tooltip: provider.filesSortAscending - ? 'Ascending' - : 'Descending', - onPressed: provider.toggleFilesSortOrder, - ), - Expanded( - child: SortMenuButton( - field: provider.filesSortField, - onChanged: provider.setFilesSortField, + // A plain width, not Expanded - this row scrolls + // horizontally now, which gives every control unbounded + // width to lay out in, so a flex child would throw. + SizedBox( + width: 130, + child: SortMenuButton( + field: provider.filesSortField, + onChanged: provider.setFilesSortField, + ), ), - ), - ToggleIconButton( - icon: provider.showFavoritesOnlyFiles - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - isSelected: provider.showFavoritesOnlyFiles, - onTap: provider.toggleFavoritesFilterFiles, - tooltip: 'Favorites only', - ), - const SizedBox(width: 4), - ToggleIconButton( - icon: provider.showHiddenFiles - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: provider.showHiddenFiles, - onTap: () => provider.toggleShowHiddenFiles(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: provider.storageScope == StorageScope.cloud, - onTap: () => provider.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: - provider.storageScope == StorageScope.external, - onTap: () => - provider.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !provider.isGridView, - onTap: () => provider.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: provider.isGridView, - onTap: () => provider.setGridView(true), - tooltip: 'Grid view', - ), - ], - ), - ], + ToggleIconButton( + icon: provider.showFavoritesOnlyFiles + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + isSelected: provider.showFavoritesOnlyFiles, + onTap: provider.toggleFavoritesFilterFiles, + tooltip: 'Favorites only', + ), + const SizedBox(width: 4), + ToggleIconButton( + icon: provider.showHiddenFiles + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + isSelected: provider.showHiddenFiles, + onTap: () => provider.toggleShowHiddenFiles(), + tooltip: 'Show hidden files', + ), + const SizedBox(width: 4), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Symbols.circles_rounded, + isSelected: provider.storageScope == StorageScope.cloud, + onTap: () => + provider.setStorageScope(StorageScope.cloud), + tooltip: 'Cloud storage', + ), + ToggleIconButton( + icon: Symbols.hard_drive_rounded, + isSelected: + provider.storageScope == StorageScope.external, + onTap: () => + provider.setStorageScope(StorageScope.external), + tooltip: 'External storage', + ), + ], + ), + const SizedBox(width: 8), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.select_all_rounded, + isSelected: + provider.filesTypeFilter == FilesTypeFilter.all, + onTap: () => + provider.setFilesTypeFilter(FilesTypeFilter.all), + tooltip: 'Files & folders', + ), + ToggleIconButton( + icon: Icons.insert_drive_file_outlined, + isSelected: + provider.filesTypeFilter == + FilesTypeFilter.filesOnly, + onTap: () => provider.setFilesTypeFilter( + FilesTypeFilter.filesOnly, + ), + tooltip: 'Files only', + ), + ToggleIconButton( + icon: Icons.folder_outlined, + isSelected: + provider.filesTypeFilter == + FilesTypeFilter.foldersOnly, + onTap: () => provider.setFilesTypeFilter( + FilesTypeFilter.foldersOnly, + ), + tooltip: 'Folders only', + ), + ], + ), + const SizedBox(width: 8), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.view_list_rounded, + isSelected: !provider.isGridView, + onTap: () => provider.setGridView(false), + tooltip: 'List view', + ), + ToggleIconButton( + icon: Icons.grid_view_rounded, + isSelected: provider.isGridView, + onTap: () => provider.setGridView(true), + tooltip: 'Grid view', + ), + ], + ), + ], + ), ), ), if (hasBreadcrumbs) ...[ @@ -580,7 +689,12 @@ class _FilesViewState extends State { ), delegate: SliverChildBuilderDelegate((context, index) { final item = provider.items[index]; - return _buildGridCard(context, item, provider); + return _FolderEnterAnimation( + key: ValueKey('${provider.currentFolderPath}::${item.id}'), + index: index, + fromRight: navigatingDeeper, + child: _buildGridCard(context, item, provider), + ); }, childCount: provider.items.length), ), ) @@ -591,7 +705,12 @@ class _FilesViewState extends State { sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = provider.items[index]; - return _buildListTile(context, item, provider); + return _FolderEnterAnimation( + key: ValueKey('${provider.currentFolderPath}::${item.id}'), + index: index, + fromRight: navigatingDeeper, + child: _buildListTile(context, item, provider), + ); }, childCount: provider.items.length), ), ),