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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user