From 3c60f8c7094aaa29ce4a90a7cff254594aaa59c5 Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Mon, 5 Oct 2026 22:34:11 -0400 Subject: [PATCH] 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 --- .claude/context/styling.md | 12 ++ lib/providers/files_controller.dart | 92 ++++++--- lib/providers/photos_controller.dart | 25 ++- lib/views/files_view.dart | 263 ++++++++++++++++-------- lib/views/photos_view.dart | 31 ++- lib/widgets/files_controls_row.dart | 36 ++-- lib/widgets/filter_mode_rows.dart | 130 ++++++++++++ test/providers/files_filters_test.dart | 129 ++++++++++++ test/widgets/filter_mode_rows_test.dart | 76 +++++++ 9 files changed, 634 insertions(+), 160 deletions(-) create mode 100644 lib/widgets/filter_mode_rows.dart create mode 100644 test/providers/files_filters_test.dart create mode 100644 test/widgets/filter_mode_rows_test.dart diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 4bf437c..6e0c4ea 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -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` diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index e3309b5..1ed4b8b 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -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 _folderSortField = {}; final Map _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 applyCommonFilters( List 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 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 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 = {}; for (final path in { diff --git a/lib/providers/photos_controller.dart b/lib/providers/photos_controller.dart index b5b4665..80f1e48 100644 --- a/lib/providers/photos_controller.dart +++ b/lib/providers/photos_controller.dart @@ -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 toggleShowHidden() async { - if (!_showHidden) { + /// Gated the same way as [FilesController.setHiddenFilter]. + Future 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), ); } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index c15e109..f0f7088 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -76,6 +76,57 @@ class FilesView extends StatefulWidget { State 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 { final Set _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 { ), ); + // 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 itemSlivers( + List 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 []; + final bodySlivers = [ + 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 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 { ), ), ) - 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 diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart index 51bd79b..a358b61 100644 --- a/lib/views/photos_view.dart +++ b/lib/views/photos_view.dart @@ -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 { 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 { 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, + ), ], ), ), diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart index abde3ee..ac69d60 100644 --- a/lib/widgets/files_controls_row.dart +++ b/lib/widgets/files_controls_row.dart @@ -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(); 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, + ), + ], ], ), ), diff --git a/lib/widgets/filter_mode_rows.dart b/lib/widgets/filter_mode_rows.dart new file mode 100644 index 0000000..d2ed936 --- /dev/null +++ b/lib/widgets/filter_mode_rows.dart @@ -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 onChanged; + + const HiddenFilesFilterRow({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterModeRow( + 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 onChanged; + + const StorageScopeRow({ + super.key, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterModeRow( + 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 extends StatelessWidget { + final IconData icon; + final String title; + final T value; + final ValueChanged onChanged; + final List> 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( + fill: true, + onSurface: true, + labelOnlySelected: true, + value: value, + onChanged: onChanged, + options: options, + ), + ], + ); + } +} diff --git a/test/providers/files_filters_test.dart b/test/providers/files_filters_test.dart new file mode 100644 index 0000000..060c3ce --- /dev/null +++ b/test/providers/files_filters_test.dart @@ -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' ? {} : null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (call) async => call.method == 'check' ? ['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 paths(List 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 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 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 load(Map 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); + }); + }); +} diff --git a/test/widgets/filter_mode_rows_test.dart b/test/widgets/filter_mode_rows_test.dart new file mode 100644 index 0000000..ac51644 --- /dev/null +++ b/test/widgets/filter_mode_rows_test.dart @@ -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); + }); + }); +}