Filters: three-way hidden files and external storage
Hidden: hide / only hidden / all + hidden. External: cloud only / only external / all + external; with 'all' Files shows the external items in a collapsible section. Old show-hidden bool prefs are still read as fallback. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
944b87b054
commit
3c60f8c709
@@ -135,6 +135,18 @@ blocks are noted where they matter:
|
||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||
Offline), and reused as-is by Favorites.
|
||||
- Hidden files and external storage are each a three-way segmented row
|
||||
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||
in both the Files and Photos filter sheets), not toggles:
|
||||
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
|
||||
cloud (default) / external / all. Files and Photos each persist their own
|
||||
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||
`StorageScope` stays shared. Leaving `hide` is still behind the
|
||||
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
|
||||
regular items first, then a collapsible "External storage" section
|
||||
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
||||
holding the external ones; Photos/Favorites just merge them into one list.
|
||||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||
trail Files uses in place of the shared
|
||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||
|
||||
@@ -9,10 +9,16 @@ import 'session_controller.dart';
|
||||
|
||||
enum FileSortField { name, dateCreated, dateModified, size }
|
||||
|
||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external }
|
||||
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||
/// external items in their own collapsible section then). Shared across
|
||||
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external, all }
|
||||
|
||||
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
|
||||
/// default), show [only] them, or [include] them alongside everything else.
|
||||
/// Files and Photos each keep their own (see [applyCommonFilters]).
|
||||
enum HiddenFilesFilter { hide, only, include }
|
||||
|
||||
/// The Files tab's files/folders/both filter — independent of and applied
|
||||
/// after [StorageScope]/favorites/hidden filtering.
|
||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
||||
static const _prefStorageScope = 'ui_storage_scope';
|
||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||
static const _prefHiddenFilter = 'ui_hidden_filter';
|
||||
static const _prefFolderSort = 'ui_folder_sort';
|
||||
static const prefCachePolicy = 'ui_cache_policy';
|
||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
||||
bool _isGridView = false;
|
||||
StorageScope _storageScope = StorageScope.cloud;
|
||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||
bool _showHiddenFiles = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
|
||||
final Map<String, FileSortField> _folderSortField = {};
|
||||
final Map<String, bool> _folderSortAscending = {};
|
||||
@@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier
|
||||
bool get isGridView => _isGridView;
|
||||
StorageScope get storageScope => _storageScope;
|
||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||
bool get showHiddenFiles => _showHiddenFiles;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||
|
||||
@@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier
|
||||
List<NextcloudItem> applyCommonFilters(
|
||||
List<NextcloudItem> source, {
|
||||
required bool showFavoritesOnly,
|
||||
required bool showHidden,
|
||||
required HiddenFilesFilter hidden,
|
||||
bool applyStorageScope = true,
|
||||
}) {
|
||||
var filtered = source;
|
||||
if (showFavoritesOnly) {
|
||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||
}
|
||||
if (!showHidden) {
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
switch (hidden) {
|
||||
case HiddenFilesFilter.hide:
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
case HiddenFilesFilter.only:
|
||||
filtered = filtered.where(_isHiddenItem).toList();
|
||||
case HiddenFilesFilter.include:
|
||||
break;
|
||||
}
|
||||
if (applyStorageScope) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(item) => _storageScope == StorageScope.external
|
||||
? item.isExternalStorage
|
||||
: !item.isExternalStorage,
|
||||
)
|
||||
.toList();
|
||||
switch (_storageScope) {
|
||||
case StorageScope.cloud:
|
||||
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||
case StorageScope.external:
|
||||
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||
case StorageScope.all:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier
|
||||
// Files itself doesn't filter by favorite - that's the dedicated
|
||||
// Favorites tab's job.
|
||||
showFavoritesOnly: false,
|
||||
showHidden: _showHiddenFiles,
|
||||
hidden: _hiddenFilter,
|
||||
applyStorageScope: applyStorageScope,
|
||||
);
|
||||
switch (_filesTypeFilter) {
|
||||
@@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier
|
||||
_isGridView = false;
|
||||
_storageScope = StorageScope.cloud;
|
||||
_filesTypeFilter = FilesTypeFilter.all;
|
||||
_showHiddenFiles = false;
|
||||
_hiddenFilter = HiddenFilesFilter.hide;
|
||||
_cachePolicy = defaultCachePolicy;
|
||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||
_isLoading = false;
|
||||
@@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier
|
||||
(f) => f.name == filesTypeFilterName,
|
||||
orElse: () => FilesTypeFilter.all,
|
||||
);
|
||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||
_hiddenFilter = loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilter),
|
||||
k(_prefShowHiddenFiles),
|
||||
);
|
||||
|
||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||
if (folderSortJson != null) {
|
||||
@@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier
|
||||
);
|
||||
}
|
||||
|
||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
||||
/// never exposes anything, so that direction is always allowed instantly.
|
||||
Future<void> toggleShowHiddenFiles() async {
|
||||
if (!_showHiddenFiles) {
|
||||
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||
/// never exposes anything, so that direction is always allowed instantly
|
||||
/// (and switching between the two revealing modes needs no second prompt).
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -650,14 +669,35 @@ class FilesController extends ChangeNotifier
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenFiles,
|
||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
||||
_prefHiddenFilter,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||
/// already account-namespaced by the caller.
|
||||
static HiddenFilesFilter loadHiddenFilter(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
String legacyBoolKey,
|
||||
) {
|
||||
final name = prefs.getString(key);
|
||||
if (name != null) {
|
||||
return HiddenFilesFilter.values.firstWhere(
|
||||
(f) => f.name == name,
|
||||
orElse: () => HiddenFilesFilter.hide,
|
||||
);
|
||||
}
|
||||
return (prefs.getBool(legacyBoolKey) ?? false)
|
||||
? HiddenFilesFilter.include
|
||||
: HiddenFilesFilter.hide;
|
||||
}
|
||||
|
||||
void _persistFolderSort() {
|
||||
final combined = <String, dynamic>{};
|
||||
for (final path in {
|
||||
|
||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
||||
|
||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||
static const _prefSortField = 'ui_sort_field';
|
||||
static const _prefSortAscending = 'ui_sort_ascending';
|
||||
|
||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
|
||||
bool _showFavoritesOnly = false;
|
||||
bool _showHidden = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
FileSortField _sortField = FileSortField.name;
|
||||
bool _sortAscending = true;
|
||||
|
||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||
bool get showHidden => _showHidden;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get sortField => _sortField;
|
||||
bool get sortAscending => _sortAscending;
|
||||
|
||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
||||
final filtered = files.applyCommonFilters(
|
||||
media,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
showHidden: _showHidden,
|
||||
hidden: _hiddenFilter,
|
||||
)..sort((a, b) => _compare(a, b));
|
||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||
}
|
||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
_showFavoritesOnly =
|
||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilterPhotos),
|
||||
k(_prefShowHiddenPhotos),
|
||||
);
|
||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||
_sortField = FileSortField.values.firstWhere(
|
||||
(f) => f.name == sortFieldName,
|
||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleShowHidden() async {
|
||||
if (!_showHidden) {
|
||||
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHidden = !_showHidden;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenPhotos,
|
||||
(p, key) => p.setBool(key, _showHidden),
|
||||
_prefHiddenFilterPhotos,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+181
-82
@@ -76,6 +76,57 @@ class FilesView extends StatefulWidget {
|
||||
State<FilesView> createState() => _FilesViewState();
|
||||
}
|
||||
|
||||
/// The collapsible "External storage" header [StorageScope.all] puts above
|
||||
/// the external items.
|
||||
class _ExternalStorageHeader extends StatelessWidget {
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ExternalStorageHeader({
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: expanded,
|
||||
label: 'External storage',
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'External storage',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: NooText.body.copyWith(color: colors.fg3)),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
color: colors.fg3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -236,6 +287,9 @@ class _GridThumbnail extends StatelessWidget {
|
||||
|
||||
class _FilesViewState extends State<FilesView> {
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||
bool _externalExpanded = true;
|
||||
int _lastPathDepth = 1;
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
@@ -669,6 +723,131 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
);
|
||||
|
||||
// One sliver group per layout (grid / desktop table / mobile list) for
|
||||
// an arbitrary slice of items - [StorageScope.all] renders two of them,
|
||||
// the regular items and then the external ones under a collapsible
|
||||
// header, instead of one list over `browser.items`.
|
||||
List<Widget> itemSlivers(
|
||||
List<NextcloudItem> items, {
|
||||
required String section,
|
||||
bool tableHeader = true,
|
||||
}) {
|
||||
if (files.isGridView) {
|
||||
return [
|
||||
SliverPadding(
|
||||
key: ValueKey('files-grid-$section'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isDesktop) {
|
||||
return [
|
||||
if (tableHeader)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: ValueKey('files-table-$section'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
SliverPadding(
|
||||
key: ValueKey('files-list-$section'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(context, item, index, items.length),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// "All + external": external items leave the main list for their own
|
||||
// collapsible section below it. Offline has no such distinction.
|
||||
final splitExternal = !_offline && files.storageScope == StorageScope.all;
|
||||
final mainItems = splitExternal
|
||||
? browser.items.where((i) => !i.isExternalStorage).toList()
|
||||
: browser.items;
|
||||
final externalItems = splitExternal
|
||||
? browser.items.where((i) => i.isExternalStorage).toList()
|
||||
: const <NextcloudItem>[];
|
||||
final bodySlivers = <Widget>[
|
||||
if (mainItems.isNotEmpty) ...itemSlivers(mainItems, section: 'main'),
|
||||
if (externalItems.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _ExternalStorageHeader(
|
||||
count: externalItems.length,
|
||||
expanded: _externalExpanded,
|
||||
onTap: () =>
|
||||
setState(() => _externalExpanded = !_externalExpanded),
|
||||
),
|
||||
),
|
||||
if (_externalExpanded)
|
||||
...itemSlivers(
|
||||
externalItems,
|
||||
section: 'external',
|
||||
tableHeader: mainItems.isEmpty,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Pinned in both states - while browsing this is the controls row
|
||||
@@ -753,88 +932,8 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (files.isGridView)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-grid'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
)
|
||||
else if (isDesktop) ...[
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
] else
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-list'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(
|
||||
context,
|
||||
item,
|
||||
index,
|
||||
browser.items.length,
|
||||
),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
else
|
||||
...bodySlivers,
|
||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||
// once Scaffold shrinks the body above an attached bar, but floating
|
||||
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||
|
||||
+12
-19
@@ -15,6 +15,7 @@ import '../providers/settings_controller.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/details/details_sheet.dart';
|
||||
import '../widgets/filter_mode_rows.dart';
|
||||
import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/noo/core/noo_chip.dart';
|
||||
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||
@@ -404,7 +405,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
final filtersActive =
|
||||
_typeFilter != _PhotoTypeFilter.all ||
|
||||
photos.showFavoritesOnly ||
|
||||
photos.showHidden ||
|
||||
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
files.storageScope != StorageScope.cloud;
|
||||
|
||||
// A plain Row, not a horizontally-scrolling one - see
|
||||
@@ -559,26 +560,18 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showHidden,
|
||||
onChanged: (_) => photos.toggleShowHidden(),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
HiddenFilesFilterRow(
|
||||
value: photos.hiddenFilter,
|
||||
onChanged: photos.setHiddenFilter,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_chip.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
import 'noo/core/noo_toggle.dart';
|
||||
import 'filter_mode_rows.dart';
|
||||
import 'noo/lists/noo_grouped_list.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/overlays/noo_sheet.dart';
|
||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
||||
final files = context.watch<FilesController>();
|
||||
final filtersActive =
|
||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||
files.showHiddenFiles ||
|
||||
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||
|
||||
return SizedBox(
|
||||
@@ -196,29 +196,17 @@ class FilesControlsRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: files.showHiddenFiles,
|
||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
||||
),
|
||||
),
|
||||
if (showStorageScope)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
HiddenFilesFilterRow(
|
||||
value: files.hiddenFilter,
|
||||
onChanged: files.setHiddenFilter,
|
||||
),
|
||||
if (showStorageScope) ...[
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
|
||||
/// "Hidden files" row of the Files/Photos filter sheets: hide them (the
|
||||
/// default), show only them, or show them alongside everything else. Same
|
||||
/// icon-always/label-when-selected pill as the type filter above it in
|
||||
/// both sheets.
|
||||
class HiddenFilesFilterRow extends StatelessWidget {
|
||||
final HiddenFilesFilter value;
|
||||
final ValueChanged<HiddenFilesFilter> onChanged;
|
||||
|
||||
const HiddenFilesFilterRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FilterModeRow<HiddenFilesFilter>(
|
||||
icon: LucideIcons.eye,
|
||||
title: 'Hidden files',
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.hide,
|
||||
icon: LucideIcons.eyeOff,
|
||||
label: 'Hide',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.only,
|
||||
icon: LucideIcons.eye,
|
||||
label: 'Only hidden',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.include,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All + hidden',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "External storage" row of the Files/Photos filter sheets: cloud only
|
||||
/// (the default), only external storage, or both. Files shows the external
|
||||
/// items in a collapsible section for [StorageScope.all].
|
||||
class StorageScopeRow extends StatelessWidget {
|
||||
final StorageScope value;
|
||||
final ValueChanged<StorageScope> onChanged;
|
||||
|
||||
const StorageScopeRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FilterModeRow<StorageScope>(
|
||||
icon: LucideIcons.hardDrive,
|
||||
title: 'External storage',
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: StorageScope.cloud,
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'Cloud only',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: StorageScope.external,
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: 'Only external',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: StorageScope.all,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All + external',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterModeRow<T> extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
final List<NooSegmentOption<T>> options;
|
||||
|
||||
const _FilterModeRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
NooSegmentedControl<T>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: options,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/models/nextcloud_item.dart';
|
||||
import 'package:noo/providers/connectivity_controller.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
|
||||
NextcloudItem _item(String path, {String? mountType}) => NextcloudItem(
|
||||
id: path,
|
||||
name: path.split('/').last,
|
||||
path: path,
|
||||
type: NextcloudItemType.file,
|
||||
size: 1,
|
||||
lastModified: DateTime(2026),
|
||||
mountType: mountType,
|
||||
);
|
||||
|
||||
/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and
|
||||
/// how a pref saved before the three-way hidden filter is read back.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||
);
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
final items = [
|
||||
_item('/a.txt'),
|
||||
_item('/.secret'),
|
||||
_item('/docs/.git/config'),
|
||||
_item('/nas', mountType: 'external'),
|
||||
_item('/.nas-hidden', mountType: 'external'),
|
||||
];
|
||||
List<String> paths(List<NextcloudItem> l) => l.map((i) => i.path).toList();
|
||||
|
||||
FilesController build() =>
|
||||
FilesController(SessionController(ConnectivityController()));
|
||||
|
||||
test('hidden filter: hide / only / include', () {
|
||||
final files = build()..setStorageScope(StorageScope.all);
|
||||
List<String> run(HiddenFilesFilter h) => paths(
|
||||
files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h),
|
||||
);
|
||||
expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']);
|
||||
expect(run(HiddenFilesFilter.only), [
|
||||
'/.secret',
|
||||
'/docs/.git/config',
|
||||
'/.nas-hidden',
|
||||
]);
|
||||
expect(run(HiddenFilesFilter.include), paths(items));
|
||||
});
|
||||
|
||||
test('storage scope: cloud / external / all', () {
|
||||
final files = build();
|
||||
List<String> run(StorageScope s) {
|
||||
files.setStorageScope(s);
|
||||
return paths(
|
||||
files.applyCommonFilters(
|
||||
items,
|
||||
showFavoritesOnly: false,
|
||||
hidden: HiddenFilesFilter.include,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expect(run(StorageScope.cloud), [
|
||||
'/a.txt',
|
||||
'/.secret',
|
||||
'/docs/.git/config',
|
||||
]);
|
||||
expect(run(StorageScope.external), ['/nas', '/.nas-hidden']);
|
||||
expect(run(StorageScope.all), paths(items));
|
||||
});
|
||||
|
||||
test('hidden + external combine', () {
|
||||
final files = build()..setStorageScope(StorageScope.external);
|
||||
expect(
|
||||
paths(
|
||||
files.applyCommonFilters(
|
||||
items,
|
||||
showFavoritesOnly: false,
|
||||
hidden: HiddenFilesFilter.only,
|
||||
),
|
||||
),
|
||||
['/.nas-hidden'],
|
||||
);
|
||||
});
|
||||
|
||||
group('loadHiddenFilter', () {
|
||||
Future<HiddenFilesFilter> load(Map<String, Object> values) async {
|
||||
SharedPreferences.setMockInitialValues(values);
|
||||
return FilesController.loadHiddenFilter(
|
||||
await SharedPreferences.getInstance(),
|
||||
'new',
|
||||
'old',
|
||||
);
|
||||
}
|
||||
|
||||
test('defaults to hide', () async {
|
||||
expect(await load({}), HiddenFilesFilter.hide);
|
||||
});
|
||||
|
||||
test('the old show-hidden bool maps to hide / include', () async {
|
||||
expect(await load({'old': true}), HiddenFilesFilter.include);
|
||||
expect(await load({'old': false}), HiddenFilesFilter.hide);
|
||||
});
|
||||
|
||||
test('the new key wins and an unknown value falls back to hide', () async {
|
||||
expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only);
|
||||
expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/widgets/filter_mode_rows.dart';
|
||||
|
||||
import 'noo/noo_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('HiddenFilesFilterRow', () {
|
||||
testNooWidgets(
|
||||
'only the selected option shows its label, tapping another reports it',
|
||||
(tester, theme, c) async {
|
||||
var value = HiddenFilesFilter.hide;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 360,
|
||||
child: HiddenFilesFilterRow(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Hidden files'), findsOneWidget);
|
||||
expect(find.text('Hide'), findsOneWidget);
|
||||
expect(find.text('Only hidden'), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, HiddenFilesFilter.include);
|
||||
expect(find.text('All + hidden'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('StorageScopeRow', () {
|
||||
testNooWidgets('offers cloud only / only external / all + external', (
|
||||
tester,
|
||||
theme,
|
||||
c,
|
||||
) async {
|
||||
var value = StorageScope.cloud;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 360,
|
||||
child: StorageScopeRow(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('External storage'), findsOneWidget);
|
||||
expect(find.text('Cloud only'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.hardDrive).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, StorageScope.external);
|
||||
expect(find.text('Only external'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, StorageScope.all);
|
||||
expect(find.text('All + external'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user