Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813c613249 | ||
|
|
c0a67f3efe | ||
|
|
5fbac333d5 | ||
|
|
3c60f8c709 | ||
|
|
944b87b054 | ||
|
|
d6448984d9 | ||
|
|
52abd16983 | ||
|
|
cb66b4a0ad |
@@ -29,6 +29,13 @@ widgets. Key points:
|
|||||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||||
|
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
|
||||||
|
`surface` (cards, selection bar, rows) from the container roles so it
|
||||||
|
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
|
||||||
|
`surfaceContainer` in dark. The dynamic_color package seeds the container
|
||||||
|
roles from the primary color rather than the OS palette, and in light mode
|
||||||
|
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
|
||||||
|
made cards blend into the page ("the selection bar has no background").
|
||||||
- **Font**: Inter via `google_fonts`, applied through
|
- **Font**: Inter via `google_fonts`, applied through
|
||||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
@@ -135,6 +142,18 @@ blocks are noted where they matter:
|
|||||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||||
Offline), and reused as-is by Favorites.
|
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
|
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||||
trail Files uses in place of the shared
|
trail Files uses in place of the shared
|
||||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||||
@@ -231,7 +250,18 @@ blocks are noted where they matter:
|
|||||||
don't confuse the two: this one is still non-blurred, just inset with a
|
don't confuse the two: this one is still non-blurred, just inset with a
|
||||||
`line` border and `nooDialogShadow` (the app's one other shadow user - see
|
`line` border and `nooDialogShadow` (the app's one other shadow user - see
|
||||||
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||||||
either). The host `Scaffold` needs `extendBody: true` while it's active
|
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||||
|
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||||
|
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||||
|
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||||
|
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||||
|
styles) swaps the bar's solid `surface` for a 0.72-alpha one over a
|
||||||
|
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops
|
||||||
|
`nooDialogShadow` (it would show through the glass). A frosted bar only
|
||||||
|
reads as glass with content behind it, so the shell uses
|
||||||
|
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||||
|
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||||
|
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
|
||||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||||
which also means every tab's own scrollable list has to reserve enough
|
which also means every tab's own scrollable list has to reserve enough
|
||||||
bottom padding to clear the bar - nothing does that automatically once the
|
bottom padding to clear the bar - nothing does that automatically once the
|
||||||
|
|||||||
@@ -360,7 +360,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -486,7 +486,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -537,7 +537,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -36,7 +36,7 @@ import 'widgets/create_menu.dart';
|
|||||||
import 'widgets/noo/core/noo_avatar.dart';
|
import 'widgets/noo/core/noo_avatar.dart';
|
||||||
import 'widgets/noo/core/noo_button.dart';
|
import 'widgets/noo/core/noo_button.dart';
|
||||||
import 'widgets/noo/core/noo_fab.dart';
|
import 'widgets/noo/core/noo_fab.dart';
|
||||||
import 'widgets/noo/nav/noo_nav_style.dart';
|
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||||
import 'widgets/noo/noo_layout.dart';
|
import 'widgets/noo/noo_layout.dart';
|
||||||
@@ -526,8 +526,12 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||||
// Floating needs the body to draw behind the bar's own transparent
|
// Floating needs the body to draw behind the bar's own transparent
|
||||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||||
// short of it like attached does.
|
// short of it like attached does; a frosted bar needs it too, or
|
||||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
// there'd be nothing behind it to blur.
|
||||||
|
extendBody: NooBottomBar.drawsBehindBody(
|
||||||
|
bottomBarStyle,
|
||||||
|
settings.bottomBarFrosted,
|
||||||
|
),
|
||||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
||||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||||
@@ -549,6 +553,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
style: navStyle,
|
style: navStyle,
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
|
frosted: settings.bottomBarFrosted,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ import 'session_controller.dart';
|
|||||||
|
|
||||||
enum FileSortField { name, dateCreated, dateModified, size }
|
enum FileSortField { name, dateCreated, dateModified, size }
|
||||||
|
|
||||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
/// external items in their own collapsible section then). Shared across
|
||||||
enum StorageScope { cloud, external }
|
/// 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
|
/// The Files tab's files/folders/both filter — independent of and applied
|
||||||
/// after [StorageScope]/favorites/hidden filtering.
|
/// after [StorageScope]/favorites/hidden filtering.
|
||||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
|||||||
static const _prefStorageScope = 'ui_storage_scope';
|
static const _prefStorageScope = 'ui_storage_scope';
|
||||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||||
|
static const _prefHiddenFilter = 'ui_hidden_filter';
|
||||||
static const _prefFolderSort = 'ui_folder_sort';
|
static const _prefFolderSort = 'ui_folder_sort';
|
||||||
static const prefCachePolicy = 'ui_cache_policy';
|
static const prefCachePolicy = 'ui_cache_policy';
|
||||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
|||||||
bool _isGridView = false;
|
bool _isGridView = false;
|
||||||
StorageScope _storageScope = StorageScope.cloud;
|
StorageScope _storageScope = StorageScope.cloud;
|
||||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||||
bool _showHiddenFiles = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
|
|
||||||
final Map<String, FileSortField> _folderSortField = {};
|
final Map<String, FileSortField> _folderSortField = {};
|
||||||
final Map<String, bool> _folderSortAscending = {};
|
final Map<String, bool> _folderSortAscending = {};
|
||||||
@@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier
|
|||||||
bool get isGridView => _isGridView;
|
bool get isGridView => _isGridView;
|
||||||
StorageScope get storageScope => _storageScope;
|
StorageScope get storageScope => _storageScope;
|
||||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||||
bool get showHiddenFiles => _showHiddenFiles;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||||
|
|
||||||
@@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier
|
|||||||
List<NextcloudItem> applyCommonFilters(
|
List<NextcloudItem> applyCommonFilters(
|
||||||
List<NextcloudItem> source, {
|
List<NextcloudItem> source, {
|
||||||
required bool showFavoritesOnly,
|
required bool showFavoritesOnly,
|
||||||
required bool showHidden,
|
required HiddenFilesFilter hidden,
|
||||||
bool applyStorageScope = true,
|
bool applyStorageScope = true,
|
||||||
}) {
|
}) {
|
||||||
var filtered = source;
|
var filtered = source;
|
||||||
if (showFavoritesOnly) {
|
if (showFavoritesOnly) {
|
||||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||||
}
|
}
|
||||||
if (!showHidden) {
|
switch (hidden) {
|
||||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
case HiddenFilesFilter.hide:
|
||||||
|
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||||
|
case HiddenFilesFilter.only:
|
||||||
|
filtered = filtered.where(_isHiddenItem).toList();
|
||||||
|
case HiddenFilesFilter.include:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (applyStorageScope) {
|
if (applyStorageScope) {
|
||||||
filtered = filtered
|
switch (_storageScope) {
|
||||||
.where(
|
case StorageScope.cloud:
|
||||||
(item) => _storageScope == StorageScope.external
|
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||||
? item.isExternalStorage
|
case StorageScope.external:
|
||||||
: !item.isExternalStorage,
|
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||||
)
|
case StorageScope.all:
|
||||||
.toList();
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
@@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier
|
|||||||
// Files itself doesn't filter by favorite - that's the dedicated
|
// Files itself doesn't filter by favorite - that's the dedicated
|
||||||
// Favorites tab's job.
|
// Favorites tab's job.
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
showHidden: _showHiddenFiles,
|
hidden: _hiddenFilter,
|
||||||
applyStorageScope: applyStorageScope,
|
applyStorageScope: applyStorageScope,
|
||||||
);
|
);
|
||||||
switch (_filesTypeFilter) {
|
switch (_filesTypeFilter) {
|
||||||
@@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier
|
|||||||
_isGridView = false;
|
_isGridView = false;
|
||||||
_storageScope = StorageScope.cloud;
|
_storageScope = StorageScope.cloud;
|
||||||
_filesTypeFilter = FilesTypeFilter.all;
|
_filesTypeFilter = FilesTypeFilter.all;
|
||||||
_showHiddenFiles = false;
|
_hiddenFilter = HiddenFilesFilter.hide;
|
||||||
_cachePolicy = defaultCachePolicy;
|
_cachePolicy = defaultCachePolicy;
|
||||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier
|
|||||||
(f) => f.name == filesTypeFilterName,
|
(f) => f.name == filesTypeFilterName,
|
||||||
orElse: () => FilesTypeFilter.all,
|
orElse: () => FilesTypeFilter.all,
|
||||||
);
|
);
|
||||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
_hiddenFilter = loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
k(_prefHiddenFilter),
|
||||||
|
k(_prefShowHiddenFiles),
|
||||||
|
);
|
||||||
|
|
||||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||||
if (folderSortJson != null) {
|
if (folderSortJson != null) {
|
||||||
@@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||||
/// never exposes anything, so that direction is always allowed instantly.
|
/// never exposes anything, so that direction is always allowed instantly
|
||||||
Future<void> toggleShowHiddenFiles() async {
|
/// (and switching between the two revealing modes needs no second prompt).
|
||||||
if (!_showHiddenFiles) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -650,14 +669,35 @@ class FilesController extends ChangeNotifier
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHiddenFiles = !_showHiddenFiles;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenFiles,
|
_prefHiddenFilter,
|
||||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
(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() {
|
void _persistFolderSort() {
|
||||||
final combined = <String, dynamic>{};
|
final combined = <String, dynamic>{};
|
||||||
for (final path in {
|
for (final path in {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
|
|
||||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||||
|
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||||
static const _prefSortField = 'ui_sort_field';
|
static const _prefSortField = 'ui_sort_field';
|
||||||
static const _prefSortAscending = 'ui_sort_ascending';
|
static const _prefSortAscending = 'ui_sort_ascending';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
bool _showFavoritesOnly = false;
|
bool _showFavoritesOnly = false;
|
||||||
bool _showHidden = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
FileSortField _sortField = FileSortField.name;
|
FileSortField _sortField = FileSortField.name;
|
||||||
bool _sortAscending = true;
|
bool _sortAscending = true;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||||
bool get showHidden => _showHidden;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get sortField => _sortField;
|
FileSortField get sortField => _sortField;
|
||||||
bool get sortAscending => _sortAscending;
|
bool get sortAscending => _sortAscending;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
final filtered = files.applyCommonFilters(
|
final filtered = files.applyCommonFilters(
|
||||||
media,
|
media,
|
||||||
showFavoritesOnly: _showFavoritesOnly,
|
showFavoritesOnly: _showFavoritesOnly,
|
||||||
showHidden: _showHidden,
|
hidden: _hiddenFilter,
|
||||||
)..sort((a, b) => _compare(a, b));
|
)..sort((a, b) => _compare(a, b));
|
||||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||||
_showFavoritesOnly =
|
_showFavoritesOnly =
|
||||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
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));
|
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||||
_sortField = FileSortField.values.firstWhere(
|
_sortField = FileSortField.values.firstWhere(
|
||||||
(f) => f.name == sortFieldName,
|
(f) => f.name == sortFieldName,
|
||||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShowHidden() async {
|
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||||
if (!_showHidden) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHidden = !_showHidden;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenPhotos,
|
_prefHiddenFilterPhotos,
|
||||||
(p, key) => p.setBool(key, _showHidden),
|
(p, key) => p.setString(key, filter.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
static const _prefFabStyle = 'ui_fab_style';
|
static const _prefFabStyle = 'ui_fab_style';
|
||||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
|
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
||||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
@@ -51,6 +52,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
|
bool _bottomBarFrosted = false;
|
||||||
bool _searchInBottomBar = false;
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
@@ -95,6 +97,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
bool get searchInBottomBar => _searchInBottomBar;
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
@@ -155,6 +158,8 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _navMenuStyle,
|
orElse: () => _navMenuStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_bottomBarFrosted =
|
||||||
|
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||||
_searchInBottomBar =
|
_searchInBottomBar =
|
||||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
@@ -313,6 +318,13 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setBottomBarFrosted(bool value) {
|
||||||
|
if (_bottomBarFrosted == value) return;
|
||||||
|
_bottomBarFrosted = value;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
|
||||||
|
}
|
||||||
|
|
||||||
void setNavMenuStyle(NooNavMenuStyle style) {
|
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||||
if (_navMenuStyle == style) return;
|
if (_navMenuStyle == style) return;
|
||||||
_navMenuStyle = style;
|
_navMenuStyle = style;
|
||||||
|
|||||||
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
|||||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||||
|
///
|
||||||
|
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||||
|
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||||
|
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||||
|
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||||
|
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||||
|
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||||
|
/// off the page.
|
||||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
final dark = cs.brightness == Brightness.dark;
|
||||||
|
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||||
return NooColors(
|
return NooColors(
|
||||||
bg: cs.surface,
|
bg: cs.surface,
|
||||||
surface: cs.surfaceContainerLow,
|
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||||
surface2: cs.surfaceContainerHigh,
|
surface2: cs.surfaceContainerHigh,
|
||||||
surface3: cs.surfaceContainerHighest,
|
surface3: cs.surfaceContainerHighest,
|
||||||
line: cs.outlineVariant,
|
line: cs.outlineVariant,
|
||||||
|
|||||||
@@ -76,6 +76,57 @@ class FilesView extends StatefulWidget {
|
|||||||
State<FilesView> createState() => _FilesViewState();
|
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
|
/// 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
|
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||||
/// and remounts the Element instead of just updating it in place - that's
|
/// 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> {
|
class _FilesViewState extends State<FilesView> {
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||||
|
bool _externalExpanded = true;
|
||||||
int _lastPathDepth = 1;
|
int _lastPathDepth = 1;
|
||||||
|
|
||||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||||
@@ -245,9 +299,19 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
/// The folder data source for this tab. Watches the concrete controller
|
/// The folder data source for this tab. Watches the concrete controller
|
||||||
/// (that's what's registered as a provider), then hands it back as the
|
/// (that's what's registered as a provider), then hands it back as the
|
||||||
/// [FolderBrowser] the rest of the view actually needs.
|
/// [FolderBrowser] the rest of the view actually needs.
|
||||||
FolderBrowser _browserOf(BuildContext context) => _offline
|
///
|
||||||
? context.watch<OfflineController>()
|
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||||
: context.watch<FilesController>();
|
/// of `build` asserts in debug builds (silently swallowing the tap).
|
||||||
|
FolderBrowser _browserOf(BuildContext context, {bool listen = true}) {
|
||||||
|
if (listen) {
|
||||||
|
return _offline
|
||||||
|
? context.watch<OfflineController>()
|
||||||
|
: context.watch<FilesController>();
|
||||||
|
}
|
||||||
|
return _offline
|
||||||
|
? context.read<OfflineController>()
|
||||||
|
: context.read<FilesController>();
|
||||||
|
}
|
||||||
|
|
||||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
/// The on-device copy of [item] for the Offline tab; null online, where
|
||||||
/// thumbnails come from server previews instead.
|
/// thumbnails come from server previews instead.
|
||||||
@@ -287,7 +351,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
NextcloudItem item, {
|
NextcloudItem item, {
|
||||||
required bool picking,
|
required bool picking,
|
||||||
}) {
|
}) {
|
||||||
final browser = _browserOf(context);
|
final browser = _browserOf(context, listen: false);
|
||||||
if (picking) {
|
if (picking) {
|
||||||
_handlePickTap(context, item);
|
_handlePickTap(context, item);
|
||||||
} else if (_isSelecting) {
|
} else if (_isSelecting) {
|
||||||
@@ -659,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 = [
|
final List<Widget> contentSlivers = [
|
||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Pinned in both states - while browsing this is the controls row
|
// Pinned in both states - while browsing this is the controls row
|
||||||
@@ -743,94 +932,17 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (files.isGridView)
|
else
|
||||||
SliverPadding(
|
...bodySlivers,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||||
// once Scaffold shrinks the body above an attached bar, but floating
|
// once Scaffold shrinks the body above an attached bar, but floating
|
||||||
// draws the body behind the bar instead, so it needs the bar's own
|
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||||
// footprint added on top or the last row ends up under it.
|
// needs the bar's own footprint added on top or the last row ends up
|
||||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
// under it.
|
||||||
NooBottomBarStyle.floating)
|
if (NooBottomBar.drawsBehindBody(
|
||||||
|
context.watch<SettingsController>().bottomBarStyle,
|
||||||
|
context.watch<SettingsController>().bottomBarFrosted,
|
||||||
|
))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: bottomBarClearance(context)),
|
child: SizedBox(height: bottomBarClearance(context)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../providers/settings_controller.dart';
|
|||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/details/details_sheet.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_button.dart';
|
||||||
import '../widgets/noo/core/noo_chip.dart';
|
import '../widgets/noo/core/noo_chip.dart';
|
||||||
import '../widgets/noo/core/noo_segmented_control.dart';
|
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||||
@@ -404,7 +405,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
final filtersActive =
|
final filtersActive =
|
||||||
_typeFilter != _PhotoTypeFilter.all ||
|
_typeFilter != _PhotoTypeFilter.all ||
|
||||||
photos.showFavoritesOnly ||
|
photos.showFavoritesOnly ||
|
||||||
photos.showHidden ||
|
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||||
files.storageScope != StorageScope.cloud;
|
files.storageScope != StorageScope.cloud;
|
||||||
|
|
||||||
// A plain Row, not a horizontally-scrolling one - see
|
// A plain Row, not a horizontally-scrolling one - see
|
||||||
@@ -521,32 +522,36 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Files' own type filter - not a fully-labelled track, so
|
// Files' own type filter - not a fully-labelled track, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<_PhotoTypeFilter>(
|
FilterSection(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
value: _typeFilter,
|
fill: true,
|
||||||
onChanged: (filter) => setState(() {
|
onSurface: true,
|
||||||
_typeFilter = filter;
|
labelOnlySelected: true,
|
||||||
setSheetState(() {});
|
value: _typeFilter,
|
||||||
}),
|
onChanged: (filter) => setState(() {
|
||||||
options: const [
|
_typeFilter = filter;
|
||||||
NooSegmentOption(
|
setSheetState(() {});
|
||||||
value: _PhotoTypeFilter.all,
|
}),
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: _PhotoTypeFilter.image,
|
label: 'All',
|
||||||
icon: LucideIcons.image,
|
),
|
||||||
label: 'Photos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.image,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.image,
|
||||||
value: _PhotoTypeFilter.video,
|
label: 'Photos',
|
||||||
icon: LucideIcons.film,
|
),
|
||||||
label: 'Videos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.video,
|
||||||
],
|
icon: LucideIcons.film,
|
||||||
|
label: 'Videos',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
NooGroupedList(
|
||||||
@@ -559,26 +564,18 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart';
|
|||||||
class BottomNavBar extends StatelessWidget {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
@@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
this.frosted = false,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
@@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
return NooBottomBar(
|
return NooBottomBar(
|
||||||
style: style,
|
style: style,
|
||||||
barStyle: barStyle,
|
barStyle: barStyle,
|
||||||
|
frosted: frosted,
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final tab in tabs)
|
for (final tab in tabs)
|
||||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
|||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'noo/core/noo_chip.dart';
|
import 'noo/core/noo_chip.dart';
|
||||||
import 'noo/core/noo_segmented_control.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_grouped_list.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'noo/overlays/noo_sheet.dart';
|
import 'noo/overlays/noo_sheet.dart';
|
||||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final filtersActive =
|
final filtersActive =
|
||||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||||
files.showHiddenFiles ||
|
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -171,54 +171,46 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Photos' own type filter below - not a checkmark list, so
|
// Photos' own type filter below - not a checkmark list, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<FilesTypeFilter>(
|
FilterSection(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<FilesTypeFilter>(
|
||||||
value: files.filesTypeFilter,
|
fill: true,
|
||||||
onChanged: files.setFilesTypeFilter,
|
onSurface: true,
|
||||||
options: const [
|
labelOnlySelected: true,
|
||||||
NooSegmentOption(
|
value: files.filesTypeFilter,
|
||||||
value: FilesTypeFilter.all,
|
onChanged: files.setFilesTypeFilter,
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: FilesTypeFilter.filesOnly,
|
label: 'All',
|
||||||
icon: LucideIcons.file,
|
),
|
||||||
label: 'Files',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.filesOnly,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.file,
|
||||||
value: FilesTypeFilter.foldersOnly,
|
label: 'Files',
|
||||||
icon: LucideIcons.folder,
|
),
|
||||||
label: 'Folders',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.foldersOnly,
|
||||||
],
|
icon: LucideIcons.folder,
|
||||||
|
label: 'Folders',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
HiddenFilesFilterRow(
|
||||||
children: [
|
value: files.hiddenFilter,
|
||||||
NooSettingsRow(
|
onChanged: files.setHiddenFilter,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
if (showStorageScope) ...[
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
StorageScopeRow(
|
||||||
|
value: files.storageScope,
|
||||||
|
onChanged: files.setStorageScope,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An icon + title line above [child] - the shape every row of the Files/
|
||||||
|
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||||
|
/// control, which lives in its own file.
|
||||||
|
class FilterSection extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const FilterSection({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return FilterSection(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
child: NooSegmentedControl<T>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
labelOnlySelected: true,
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
import 'noo_nav_style.dart';
|
import 'noo_nav_style.dart';
|
||||||
@@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8;
|
|||||||
const double _kAndroidPillWidth = 56;
|
const double _kAndroidPillWidth = 56;
|
||||||
const double _kAndroidPillHeight = 32;
|
const double _kAndroidPillHeight = 32;
|
||||||
|
|
||||||
|
/// Frosted glass: the surface fill's opacity and the backdrop blur sigma -
|
||||||
|
/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome.
|
||||||
|
const double _kFrostedAlpha = 0.72;
|
||||||
|
const double _kFrostedSigma = 20;
|
||||||
|
|
||||||
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
|
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
|
||||||
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
||||||
/// what order, is the caller's business; this only draws [destinations].
|
/// what order, is the caller's business; this only draws [destinations].
|
||||||
@@ -26,6 +32,9 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
||||||
/// rather than popping in/out on the destination item itself.
|
/// rather than popping in/out on the destination item itself.
|
||||||
///
|
///
|
||||||
|
/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android
|
||||||
|
/// row below, so iOS gets the same icon-only idle tabs.
|
||||||
|
///
|
||||||
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||||
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||||
@@ -45,6 +54,13 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// body draws behind it - see `tab_state_slivers.dart`'s
|
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||||
/// `bottomBarClearance`.
|
/// `bottomBarClearance`.
|
||||||
///
|
///
|
||||||
|
/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a
|
||||||
|
/// translucent one over a backdrop blur, on both bar styles and both nav
|
||||||
|
/// styles, and drops [nooDialogShadow] (a shadow would show through the
|
||||||
|
/// glass). It only reads as glass if content draws behind the bar, so the
|
||||||
|
/// host `Scaffold` needs `extendBody: true` and each scrollable body needs
|
||||||
|
/// the same trailing clearance floating does - see [drawsBehindBody].
|
||||||
|
///
|
||||||
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||||
@@ -54,6 +70,7 @@ const double _kAndroidPillHeight = 32;
|
|||||||
class NooBottomBar extends StatelessWidget {
|
class NooBottomBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
final List<NooNavDestination> destinations;
|
final List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
@@ -64,6 +81,7 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
this.frosted = false,
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
@@ -75,10 +93,18 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||||
/// much clearance (see the class doc comment) instead of guessing.
|
/// much clearance (see the class doc comment) instead of guessing.
|
||||||
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
||||||
if (style == NooNavStyle.ios) return 50;
|
if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||||
|
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||||
|
/// frosted bar (there's nothing to blur otherwise).
|
||||||
|
static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) =>
|
||||||
|
frosted || barStyle == NooBottomBarStyle.floating;
|
||||||
|
|
||||||
/// Gap between the floating bar's bottom edge and the safe area below it
|
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||||
static const double floatingBottomMargin = 12;
|
static const double floatingBottomMargin = 12;
|
||||||
@@ -86,8 +112,10 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final ios = style == NooNavStyle.ios;
|
|
||||||
final floating = barStyle == NooBottomBarStyle.floating;
|
final floating = barStyle == NooBottomBarStyle.floating;
|
||||||
|
// Floating always gets the Android row (icon-only idle tabs, sliding
|
||||||
|
// pill) - iOS's icon-over-label row doesn't fit a pill that short.
|
||||||
|
final ios = style == NooNavStyle.ios && !floating;
|
||||||
final hasSearch = searchDestination != null && onSearchTap != null;
|
final hasSearch = searchDestination != null && onSearchTap != null;
|
||||||
// Attached folds Search into the row itself (last item); floating
|
// Attached folds Search into the row itself (last item); floating
|
||||||
// gives it a separate satellite circle instead (built below), so the
|
// gives it a separate satellite circle instead (built below), so the
|
||||||
@@ -104,17 +132,19 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||||
|
|
||||||
if (floating) {
|
if (floating) {
|
||||||
final pill = Container(
|
final pillRadius = BorderRadius.circular(28);
|
||||||
|
final pillBox = Container(
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: pillRadius,
|
||||||
boxShadow: const [nooDialogShadow],
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
|
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -145,6 +175,7 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
destination: searchDestination!,
|
destination: searchDestination!,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
size: barHeight,
|
size: barHeight,
|
||||||
|
frosted: frosted,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -154,9 +185,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
final bar = Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -164,8 +195,13 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
child: SizedBox(height: barHeight, child: row),
|
child: SizedBox(height: barHeight, child: row),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return frosted ? _blurred(bar, BorderRadius.zero) : bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _fill(NooColors colors) => frosted
|
||||||
|
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
||||||
|
: colors.surface;
|
||||||
|
|
||||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -248,6 +284,16 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||||
|
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
||||||
|
Widget _blurred(Widget child, BorderRadius radius) => ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// The floating bar's own Search entry point, next to the pill rather than
|
/// The floating bar's own Search entry point, next to the pill rather than
|
||||||
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||||
/// same fill/border as the pill so the two still read as one family.
|
/// same fill/border as the pill so the two still read as one family.
|
||||||
@@ -255,11 +301,13 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final double size;
|
final double size;
|
||||||
|
final bool frosted;
|
||||||
|
|
||||||
const _SearchSatellite({
|
const _SearchSatellite({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.size,
|
required this.size,
|
||||||
|
required this.frosted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -271,21 +319,28 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: _satelliteBody(colors),
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surface,
|
|
||||||
border: Border.all(color: colors.line),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: const [nooDialogShadow],
|
|
||||||
),
|
|
||||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _satelliteBody(NooColors colors) {
|
||||||
|
final disc = Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: frosted
|
||||||
|
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
||||||
|
: colors.surface,
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
|
),
|
||||||
|
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||||
|
);
|
||||||
|
return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IosItem extends StatelessWidget {
|
class _IosItem extends StatelessWidget {
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeRow(settings: settings),
|
_ThemeRow(settings: settings),
|
||||||
_BottomBarStyleRow(settings: settings),
|
_BottomBarStyleRow(settings: settings),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.glassWater,
|
||||||
|
label: const Text('Frosted glass bottom bar'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'A translucent, blurred bottom bar - content scrolls behind it',
|
||||||
|
),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: settings.bottomBarFrosted,
|
||||||
|
onChanged: settings.setBottomBarFrosted,
|
||||||
|
),
|
||||||
|
),
|
||||||
_NavMenuStyleRow(settings: settings),
|
_NavMenuStyleRow(settings: settings),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
|
|||||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
|||||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||||
/// Scaffold already shrinks the body above them - this is then pure
|
/// Scaffold already shrinks the body above them - this is then pure
|
||||||
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
||||||
/// bars draw over an extended body instead, so nothing reserves space for
|
/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an
|
||||||
|
/// extended body instead, so nothing reserves space for
|
||||||
/// them automatically: the clearance has to cover the bar's own footprint
|
/// them automatically: the clearance has to cover the bar's own footprint
|
||||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||||
double bottomBarClearance(BuildContext context) {
|
double bottomBarClearance(BuildContext context) {
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
final barStyle = settings.bottomBarStyle;
|
||||||
|
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
final barHeight = NooBottomBar.rowHeight(
|
final barHeight = NooBottomBar.rowHeight(
|
||||||
NooLayout.navStyle(context),
|
NooLayout.navStyle(context),
|
||||||
NooBottomBarStyle.floating,
|
barStyle,
|
||||||
);
|
);
|
||||||
|
final margin = barStyle == NooBottomBarStyle.floating
|
||||||
|
? NooBottomBar.floatingBottomMargin
|
||||||
|
: 0;
|
||||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||||
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
return barHeight + margin + safeBottom + 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||||
|
|||||||
@@ -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'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('StorageScopeRow', () {
|
||||||
|
testNooWidgets('offers cloud only / only external / all', (
|
||||||
|
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'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -74,6 +74,30 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('NooColors.fromDynamicScheme surfaces', () {
|
||||||
|
for (final brightness in Brightness.values) {
|
||||||
|
test('cards are clearly lighter than the page (${brightness.name})', () {
|
||||||
|
// The OS palette overrides `surface` (what `bg` maps to) but not the
|
||||||
|
// seeded container roles - mimic that mismatch.
|
||||||
|
final seeded = ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xFF3F6B8F),
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
final os = seeded.copyWith(
|
||||||
|
surface: brightness == Brightness.light
|
||||||
|
? const Color(0xFFF7F9FC)
|
||||||
|
: const Color(0xFF101417),
|
||||||
|
);
|
||||||
|
final c = NooColors.fromDynamicScheme(os);
|
||||||
|
// WCAG-style contrast ratio between the card and the page.
|
||||||
|
final hi = c.surface.computeLuminance();
|
||||||
|
final lo = c.bg.computeLuminance();
|
||||||
|
expect(hi, greaterThan(lo));
|
||||||
|
expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
group('NooAvatar', () {
|
group('NooAvatar', () {
|
||||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||||
tester,
|
tester,
|
||||||
|
|||||||
@@ -25,6 +25,69 @@ void main() {
|
|||||||
setUpNooTests();
|
setUpNooTests();
|
||||||
|
|
||||||
group('NooBottomBar', () {
|
group('NooBottomBar', () {
|
||||||
|
for (final style in NooNavStyle.values) {
|
||||||
|
for (final barStyle in NooBottomBarStyle.values) {
|
||||||
|
testNooWidgets(
|
||||||
|
'$style/$barStyle: frosted draws a backdrop blur, solid does not',
|
||||||
|
(tester, theme, c) async {
|
||||||
|
for (final frosted in [false, true]) {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 120,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: style,
|
||||||
|
barStyle: barStyle,
|
||||||
|
frosted: frosted,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
searchDestination: const NooNavDestination(
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
onSearchTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byType(BackdropFilter),
|
||||||
|
frosted ? findsWidgets : findsNothing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('drawsBehindBody: floating or frosted', () {
|
||||||
|
expect(
|
||||||
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('floating row height is the Android one on both nav styles', () {
|
||||||
|
expect(
|
||||||
|
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating),
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached),
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testNooWidgets('android: label sits below the icon, not beside it', (
|
testNooWidgets('android: label sits below the icon, not beside it', (
|
||||||
tester,
|
tester,
|
||||||
theme,
|
theme,
|
||||||
|
|||||||