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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:28:51 -04:00
co-authored by Claude Sonnet 5
parent bb1688ce3a
commit f78ceb7e8b
3 changed files with 228 additions and 76 deletions
+33 -1
View File
@@ -28,6 +28,10 @@ enum StorageScope { cloud, external }
/// in Settings. /// in Settings.
enum SwipeAction { none, favorite, delete, share } 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 /// How aggressively the Files tab's folder listings (not thumbnails/file
/// content - just the list of names/sizes/dates) are reused across /// content - just the list of names/sizes/dates) are reused across
/// navigation instead of refetched from the server every time. /// 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 _prefShowFavoritesOnlyFiles = 'ui_show_favorites_only';
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos'; static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
static const _prefStorageScope = 'ui_storage_scope'; static const _prefStorageScope = 'ui_storage_scope';
static const _prefFilesTypeFilter = 'ui_files_type_filter';
static const _prefShowHiddenFiles = 'ui_show_hidden'; static const _prefShowHiddenFiles = 'ui_show_hidden';
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos'; static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
static const _prefSortField = 'ui_sort_field'; static const _prefSortField = 'ui_sort_field';
@@ -141,6 +146,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
bool _showFavoritesOnlyFiles = false; bool _showFavoritesOnlyFiles = false;
bool _showFavoritesOnlyPhotos = false; bool _showFavoritesOnlyPhotos = false;
StorageScope _storageScope = StorageScope.cloud; StorageScope _storageScope = StorageScope.cloud;
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
bool _showHiddenFiles = false; bool _showHiddenFiles = false;
bool _showHiddenPhotos = false; bool _showHiddenPhotos = false;
// Photos tab sort - a single global setting (Photos has no folder concept, // 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 showFavoritesOnlyFiles => _showFavoritesOnlyFiles;
bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos; bool get showFavoritesOnlyPhotos => _showFavoritesOnlyPhotos;
StorageScope get storageScope => _storageScope; StorageScope get storageScope => _storageScope;
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
bool get showHiddenFiles => _showHiddenFiles; bool get showHiddenFiles => _showHiddenFiles;
bool get showHiddenPhotos => _showHiddenPhotos; bool get showHiddenPhotos => _showHiddenPhotos;
FileSortField get photosSortField => _photosSortField; FileSortField get photosSortField => _photosSortField;
@@ -385,11 +392,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
} }
List<NextcloudItem> get items { List<NextcloudItem> get items {
final filtered = _applyCommonFilters( var filtered = _applyCommonFilters(
_items, _items,
showFavoritesOnly: _showFavoritesOnlyFiles, showFavoritesOnly: _showFavoritesOnlyFiles,
showHidden: _showHiddenFiles, 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 field = filesSortField;
final folders = filtered.where((i) => i.isFolder).toList() final folders = filtered.where((i) => i.isFolder).toList()
@@ -565,6 +580,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_showFavoritesOnlyFiles = false; _showFavoritesOnlyFiles = false;
_showFavoritesOnlyPhotos = false; _showFavoritesOnlyPhotos = false;
_storageScope = StorageScope.cloud; _storageScope = StorageScope.cloud;
_filesTypeFilter = FilesTypeFilter.all;
_showHiddenFiles = false; _showHiddenFiles = false;
_showHiddenPhotos = false; _showHiddenPhotos = false;
_photosSortField = FileSortField.name; _photosSortField = FileSortField.name;
@@ -588,6 +604,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
orElse: () => StorageScope.cloud, 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; _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
_showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? 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 /// Only *enabling* hidden-files visibility is gated - hiding them again
/// never exposes anything, so that direction is always allowed instantly. /// never exposes anything, so that direction is always allowed instantly.
Future<void> toggleShowHiddenFiles() async { Future<void> toggleShowHiddenFiles() async {
+1
View File
@@ -35,6 +35,7 @@ class AccountStore {
'ui_sort_field', 'ui_sort_field',
'ui_sort_ascending', 'ui_sort_ascending',
'ui_folder_sort', 'ui_folder_sort',
'ui_files_type_filter',
'ui_cache_policy', 'ui_cache_policy',
'ui_cache_interval_minutes', 'ui_cache_interval_minutes',
]; ];
+194 -75
View File
@@ -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<double> _opacity;
late final Animation<Offset> _offset;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
);
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
_offset = Tween<Offset>(
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<FilesView> { class _FilesViewState extends State<FilesView> {
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
int _lastPathDepth = 1;
bool get _isSelecting => _selectedIds.isNotEmpty; bool get _isSelecting => _selectedIds.isNotEmpty;
@@ -331,6 +395,10 @@ class _FilesViewState extends State<FilesView> {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final provider = context.watch<ServerProvider>(); final provider = context.watch<ServerProvider>();
final pathDepth = provider.pathStack.length;
final navigatingDeeper = pathDepth > _lastPathDepth;
_lastPathDepth = pathDepth;
final hasBreadcrumbs = provider.pathStack.length > 1; final hasBreadcrumbs = provider.pathStack.length > 1;
final selectedItems = provider.items final selectedItems = provider.items
.where((i) => _selectedIds.contains(i.id)) .where((i) => _selectedIds.contains(i.id))
@@ -346,81 +414,122 @@ class _FilesViewState extends State<FilesView> {
children: [ children: [
SizedBox( SizedBox(
height: 44, height: 44,
child: Row( child: SingleChildScrollView(
children: [ scrollDirection: Axis.horizontal,
IconButton( child: Row(
icon: Icon( children: [
provider.filesSortAscending IconButton(
? Icons.arrow_upward_rounded icon: Icon(
: Icons.arrow_downward_rounded, provider.filesSortAscending
size: 20, ? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 20,
),
visualDensity: VisualDensity.compact,
tooltip: provider.filesSortAscending
? 'Ascending'
: 'Descending',
onPressed: provider.toggleFilesSortOrder,
), ),
visualDensity: VisualDensity.compact, // A plain width, not Expanded - this row scrolls
tooltip: provider.filesSortAscending // horizontally now, which gives every control unbounded
? 'Ascending' // width to lay out in, so a flex child would throw.
: 'Descending', SizedBox(
onPressed: provider.toggleFilesSortOrder, width: 130,
), child: SortMenuButton(
Expanded( field: provider.filesSortField,
child: SortMenuButton( onChanged: provider.setFilesSortField,
field: provider.filesSortField, ),
onChanged: provider.setFilesSortField,
), ),
), ToggleIconButton(
ToggleIconButton( icon: provider.showFavoritesOnlyFiles
icon: provider.showFavoritesOnlyFiles ? Icons.favorite_rounded
? Icons.favorite_rounded : Icons.favorite_border_rounded,
: Icons.favorite_border_rounded, isSelected: provider.showFavoritesOnlyFiles,
isSelected: provider.showFavoritesOnlyFiles, onTap: provider.toggleFavoritesFilterFiles,
onTap: provider.toggleFavoritesFilterFiles, tooltip: 'Favorites only',
tooltip: 'Favorites only', ),
), const SizedBox(width: 4),
const SizedBox(width: 4), ToggleIconButton(
ToggleIconButton( icon: provider.showHiddenFiles
icon: provider.showHiddenFiles ? Icons.visibility_rounded
? Icons.visibility_rounded : Icons.visibility_off_rounded,
: Icons.visibility_off_rounded, isSelected: provider.showHiddenFiles,
isSelected: provider.showHiddenFiles, onTap: () => provider.toggleShowHiddenFiles(),
onTap: () => provider.toggleShowHiddenFiles(), tooltip: 'Show hidden files',
tooltip: 'Show hidden files', ),
), const SizedBox(width: 4),
const SizedBox(width: 4), SegmentedIconGroup(
SegmentedIconGroup( children: [
children: [ ToggleIconButton(
ToggleIconButton( icon: Symbols.circles_rounded,
icon: Symbols.circles_rounded, isSelected: provider.storageScope == StorageScope.cloud,
isSelected: provider.storageScope == StorageScope.cloud, onTap: () =>
onTap: () => provider.setStorageScope(StorageScope.cloud), provider.setStorageScope(StorageScope.cloud),
tooltip: 'Cloud storage', tooltip: 'Cloud storage',
), ),
ToggleIconButton( ToggleIconButton(
icon: Symbols.hard_drive_rounded, icon: Symbols.hard_drive_rounded,
isSelected: isSelected:
provider.storageScope == StorageScope.external, provider.storageScope == StorageScope.external,
onTap: () => onTap: () =>
provider.setStorageScope(StorageScope.external), provider.setStorageScope(StorageScope.external),
tooltip: 'External storage', tooltip: 'External storage',
), ),
], ],
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SegmentedIconGroup( SegmentedIconGroup(
children: [ children: [
ToggleIconButton( ToggleIconButton(
icon: Icons.view_list_rounded, icon: Icons.select_all_rounded,
isSelected: !provider.isGridView, isSelected:
onTap: () => provider.setGridView(false), provider.filesTypeFilter == FilesTypeFilter.all,
tooltip: 'List view', onTap: () =>
), provider.setFilesTypeFilter(FilesTypeFilter.all),
ToggleIconButton( tooltip: 'Files & folders',
icon: Icons.grid_view_rounded, ),
isSelected: provider.isGridView, ToggleIconButton(
onTap: () => provider.setGridView(true), icon: Icons.insert_drive_file_outlined,
tooltip: 'Grid view', 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) ...[ if (hasBreadcrumbs) ...[
@@ -580,7 +689,12 @@ class _FilesViewState extends State<FilesView> {
), ),
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = provider.items[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), }, childCount: provider.items.length),
), ),
) )
@@ -591,7 +705,12 @@ class _FilesViewState extends State<FilesView> {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final item = provider.items[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), }, childCount: provider.items.length),
), ),
), ),