Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813c613249 | ||
|
|
c0a67f3efe | ||
|
|
5fbac333d5 | ||
|
|
3c60f8c709 | ||
|
|
944b87b054 | ||
|
|
d6448984d9 | ||
|
|
52abd16983 | ||
|
|
cb66b4a0ad | ||
|
|
da3b8a4f05 |
@@ -29,6 +29,13 @@ widgets. Key points:
|
||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||
`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
|
||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||
`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/
|
||||
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`
|
||||
@@ -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
|
||||
`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
|
||||
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`),
|
||||
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
|
||||
|
||||
@@ -109,3 +109,23 @@ it as your own app.
|
||||
|
||||
Contributions are welcome under the terms in
|
||||
[`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
### Why PolyForm Shield?
|
||||
|
||||
I want the code to be open to read, learn from, build, and improve, while
|
||||
keeping anyone from lifting it and shipping a competing copy of the app.
|
||||
Permissive licenses (MIT, Apache) allow exactly that, and copyleft ones
|
||||
(GPL) still allow a rebranded fork. PolyForm Shield sits in between: it is
|
||||
not an OSI "open source" license, and I'm not claiming it is, but it keeps
|
||||
the code available while protecting the project.
|
||||
|
||||
## Where development happens
|
||||
|
||||
The source of truth is a self-hosted Gitea instance, and this GitHub
|
||||
repository is a read-only mirror plus the public issue tracker. I keep it
|
||||
that way so that I own my hosting, builds, and release credentials rather
|
||||
than depending on a third party for them. You don't have to use Gitea:
|
||||
open issues and pull requests here as usual. Open PRs are imported to my
|
||||
instance, reviewed and merged there, and `main` is mirrored back here, so a
|
||||
merged PR may show as closed with a link to the commit instead of "Merged".
|
||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details.
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -486,7 +486,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -537,7 +537,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = 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_button.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_toolbar.dart';
|
||||
import 'widgets/noo/noo_layout.dart';
|
||||
@@ -526,8 +526,12 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||
// Floating needs the body to draw behind the bar's own transparent
|
||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||
// short of it like attached does.
|
||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
||||
// short of it like attached does; a frosted bar needs it too, or
|
||||
// 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`
|
||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||
@@ -549,6 +553,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
style: navStyle,
|
||||
barStyle: bottomBarStyle,
|
||||
frosted: settings.bottomBarFrosted,
|
||||
tabs: pinnedTabs,
|
||||
selectedIndex: pinnedIndex,
|
||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||
|
||||
@@ -9,10 +9,16 @@ import 'session_controller.dart';
|
||||
|
||||
enum FileSortField { name, dateCreated, dateModified, size }
|
||||
|
||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external }
|
||||
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||
/// external items in their own collapsible section then). Shared across
|
||||
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external, all }
|
||||
|
||||
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
|
||||
/// default), show [only] them, or [include] them alongside everything else.
|
||||
/// Files and Photos each keep their own (see [applyCommonFilters]).
|
||||
enum HiddenFilesFilter { hide, only, include }
|
||||
|
||||
/// The Files tab's files/folders/both filter — independent of and applied
|
||||
/// after [StorageScope]/favorites/hidden filtering.
|
||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
||||
static const _prefStorageScope = 'ui_storage_scope';
|
||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||
static const _prefHiddenFilter = 'ui_hidden_filter';
|
||||
static const _prefFolderSort = 'ui_folder_sort';
|
||||
static const prefCachePolicy = 'ui_cache_policy';
|
||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
||||
bool _isGridView = false;
|
||||
StorageScope _storageScope = StorageScope.cloud;
|
||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||
bool _showHiddenFiles = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
|
||||
final Map<String, FileSortField> _folderSortField = {};
|
||||
final Map<String, bool> _folderSortAscending = {};
|
||||
@@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier
|
||||
bool get isGridView => _isGridView;
|
||||
StorageScope get storageScope => _storageScope;
|
||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||
bool get showHiddenFiles => _showHiddenFiles;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||
|
||||
@@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier
|
||||
List<NextcloudItem> applyCommonFilters(
|
||||
List<NextcloudItem> source, {
|
||||
required bool showFavoritesOnly,
|
||||
required bool showHidden,
|
||||
required HiddenFilesFilter hidden,
|
||||
bool applyStorageScope = true,
|
||||
}) {
|
||||
var filtered = source;
|
||||
if (showFavoritesOnly) {
|
||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||
}
|
||||
if (!showHidden) {
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
switch (hidden) {
|
||||
case HiddenFilesFilter.hide:
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
case HiddenFilesFilter.only:
|
||||
filtered = filtered.where(_isHiddenItem).toList();
|
||||
case HiddenFilesFilter.include:
|
||||
break;
|
||||
}
|
||||
if (applyStorageScope) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(item) => _storageScope == StorageScope.external
|
||||
? item.isExternalStorage
|
||||
: !item.isExternalStorage,
|
||||
)
|
||||
.toList();
|
||||
switch (_storageScope) {
|
||||
case StorageScope.cloud:
|
||||
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||
case StorageScope.external:
|
||||
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||
case StorageScope.all:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier
|
||||
// Files itself doesn't filter by favorite - that's the dedicated
|
||||
// Favorites tab's job.
|
||||
showFavoritesOnly: false,
|
||||
showHidden: _showHiddenFiles,
|
||||
hidden: _hiddenFilter,
|
||||
applyStorageScope: applyStorageScope,
|
||||
);
|
||||
switch (_filesTypeFilter) {
|
||||
@@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier
|
||||
_isGridView = false;
|
||||
_storageScope = StorageScope.cloud;
|
||||
_filesTypeFilter = FilesTypeFilter.all;
|
||||
_showHiddenFiles = false;
|
||||
_hiddenFilter = HiddenFilesFilter.hide;
|
||||
_cachePolicy = defaultCachePolicy;
|
||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||
_isLoading = false;
|
||||
@@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier
|
||||
(f) => f.name == filesTypeFilterName,
|
||||
orElse: () => FilesTypeFilter.all,
|
||||
);
|
||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||
_hiddenFilter = loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilter),
|
||||
k(_prefShowHiddenFiles),
|
||||
);
|
||||
|
||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||
if (folderSortJson != null) {
|
||||
@@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier
|
||||
);
|
||||
}
|
||||
|
||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
||||
/// never exposes anything, so that direction is always allowed instantly.
|
||||
Future<void> toggleShowHiddenFiles() async {
|
||||
if (!_showHiddenFiles) {
|
||||
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||
/// never exposes anything, so that direction is always allowed instantly
|
||||
/// (and switching between the two revealing modes needs no second prompt).
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -650,14 +669,35 @@ class FilesController extends ChangeNotifier
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenFiles,
|
||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
||||
_prefHiddenFilter,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||
/// already account-namespaced by the caller.
|
||||
static HiddenFilesFilter loadHiddenFilter(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
String legacyBoolKey,
|
||||
) {
|
||||
final name = prefs.getString(key);
|
||||
if (name != null) {
|
||||
return HiddenFilesFilter.values.firstWhere(
|
||||
(f) => f.name == name,
|
||||
orElse: () => HiddenFilesFilter.hide,
|
||||
);
|
||||
}
|
||||
return (prefs.getBool(legacyBoolKey) ?? false)
|
||||
? HiddenFilesFilter.include
|
||||
: HiddenFilesFilter.hide;
|
||||
}
|
||||
|
||||
void _persistFolderSort() {
|
||||
final combined = <String, dynamic>{};
|
||||
for (final path in {
|
||||
|
||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
||||
|
||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||
static const _prefSortField = 'ui_sort_field';
|
||||
static const _prefSortAscending = 'ui_sort_ascending';
|
||||
|
||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
|
||||
bool _showFavoritesOnly = false;
|
||||
bool _showHidden = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
FileSortField _sortField = FileSortField.name;
|
||||
bool _sortAscending = true;
|
||||
|
||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||
bool get showHidden => _showHidden;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get sortField => _sortField;
|
||||
bool get sortAscending => _sortAscending;
|
||||
|
||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
||||
final filtered = files.applyCommonFilters(
|
||||
media,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
showHidden: _showHidden,
|
||||
hidden: _hiddenFilter,
|
||||
)..sort((a, b) => _compare(a, b));
|
||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||
}
|
||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
_showFavoritesOnly =
|
||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilterPhotos),
|
||||
k(_prefShowHiddenPhotos),
|
||||
);
|
||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||
_sortField = FileSortField.values.firstWhere(
|
||||
(f) => f.name == sortFieldName,
|
||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleShowHidden() async {
|
||||
if (!_showHidden) {
|
||||
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHidden = !_showHidden;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenPhotos,
|
||||
(p, key) => p.setBool(key, _showHidden),
|
||||
_prefHiddenFilterPhotos,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class SettingsController extends ChangeNotifier {
|
||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||
static const _prefFabStyle = 'ui_fab_style';
|
||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||
@@ -51,6 +52,7 @@ class SettingsController extends ChangeNotifier {
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||
bool _bottomBarFrosted = false;
|
||||
bool _searchInBottomBar = false;
|
||||
bool _useDynamicColor = true;
|
||||
bool _amoledDark = false;
|
||||
@@ -95,6 +97,7 @@ class SettingsController extends ChangeNotifier {
|
||||
Color get seedColor => _seedColor;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||
bool get searchInBottomBar => _searchInBottomBar;
|
||||
bool get useDynamicColor => _useDynamicColor;
|
||||
@@ -155,6 +158,8 @@ class SettingsController extends ChangeNotifier {
|
||||
orElse: () => _navMenuStyle,
|
||||
);
|
||||
}
|
||||
_bottomBarFrosted =
|
||||
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||
_searchInBottomBar =
|
||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||
_useDynamicColor =
|
||||
@@ -313,6 +318,13 @@ class SettingsController extends ChangeNotifier {
|
||||
_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) {
|
||||
if (_navMenuStyle == style) return;
|
||||
_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
|
||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||
/// 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) {
|
||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
||||
final dark = cs.brightness == Brightness.dark;
|
||||
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||
return NooColors(
|
||||
bg: cs.surface,
|
||||
surface: cs.surfaceContainerLow,
|
||||
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
surface2: cs.surfaceContainerHigh,
|
||||
surface3: cs.surfaceContainerHighest,
|
||||
line: cs.outlineVariant,
|
||||
|
||||
@@ -76,6 +76,57 @@ class FilesView extends StatefulWidget {
|
||||
State<FilesView> createState() => _FilesViewState();
|
||||
}
|
||||
|
||||
/// The collapsible "External storage" header [StorageScope.all] puts above
|
||||
/// the external items.
|
||||
class _ExternalStorageHeader extends StatelessWidget {
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ExternalStorageHeader({
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: expanded,
|
||||
label: 'External storage',
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'External storage',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: NooText.body.copyWith(color: colors.fg3)),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
color: colors.fg3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||
/// and remounts the Element instead of just updating it in place - that's
|
||||
@@ -236,6 +287,9 @@ class _GridThumbnail extends StatelessWidget {
|
||||
|
||||
class _FilesViewState extends State<FilesView> {
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||
bool _externalExpanded = true;
|
||||
int _lastPathDepth = 1;
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
@@ -245,9 +299,19 @@ class _FilesViewState extends State<FilesView> {
|
||||
/// 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
|
||||
/// [FolderBrowser] the rest of the view actually needs.
|
||||
FolderBrowser _browserOf(BuildContext context) => _offline
|
||||
? context.watch<OfflineController>()
|
||||
: context.watch<FilesController>();
|
||||
///
|
||||
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||
/// 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
|
||||
/// thumbnails come from server previews instead.
|
||||
@@ -287,7 +351,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
NextcloudItem item, {
|
||||
required bool picking,
|
||||
}) {
|
||||
final browser = _browserOf(context);
|
||||
final browser = _browserOf(context, listen: false);
|
||||
if (picking) {
|
||||
_handlePickTap(context, item);
|
||||
} 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 = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Pinned in both states - while browsing this is the controls row
|
||||
@@ -743,94 +932,17 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (files.isGridView)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-grid'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
)
|
||||
else if (isDesktop) ...[
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
] else
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-list'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(
|
||||
context,
|
||||
item,
|
||||
index,
|
||||
browser.items.length,
|
||||
),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
else
|
||||
...bodySlivers,
|
||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||
// once Scaffold shrinks the body above an attached bar, but floating
|
||||
// draws the body behind the bar instead, so it needs the bar's own
|
||||
// footprint added on top or the last row ends up under it.
|
||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
||||
NooBottomBarStyle.floating)
|
||||
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||
// needs the bar's own footprint added on top or the last row ends up
|
||||
// under it.
|
||||
if (NooBottomBar.drawsBehindBody(
|
||||
context.watch<SettingsController>().bottomBarStyle,
|
||||
context.watch<SettingsController>().bottomBarFrosted,
|
||||
))
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: bottomBarClearance(context)),
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../providers/settings_controller.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/details/details_sheet.dart';
|
||||
import '../widgets/filter_mode_rows.dart';
|
||||
import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/noo/core/noo_chip.dart';
|
||||
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||
@@ -404,7 +405,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
final filtersActive =
|
||||
_typeFilter != _PhotoTypeFilter.all ||
|
||||
photos.showFavoritesOnly ||
|
||||
photos.showHidden ||
|
||||
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
files.storageScope != StorageScope.cloud;
|
||||
|
||||
// A plain Row, not a horizontally-scrolling one - see
|
||||
@@ -521,32 +522,36 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
// Same "icon always, label only when selected" pill as
|
||||
// Files' own type filter - not a fully-labelled track, so
|
||||
// the two screens' filter sheets look and behave the same.
|
||||
NooSegmentedControl<_PhotoTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: _typeFilter,
|
||||
onChanged: (filter) => setState(() {
|
||||
_typeFilter = filter;
|
||||
setSheetState(() {});
|
||||
}),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.image,
|
||||
icon: LucideIcons.image,
|
||||
label: 'Photos',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.video,
|
||||
icon: LucideIcons.film,
|
||||
label: 'Videos',
|
||||
),
|
||||
],
|
||||
FilterSection(
|
||||
icon: LucideIcons.layoutGrid,
|
||||
title: 'Type',
|
||||
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: _typeFilter,
|
||||
onChanged: (filter) => setState(() {
|
||||
_typeFilter = filter;
|
||||
setSheetState(() {});
|
||||
}),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.image,
|
||||
icon: LucideIcons.image,
|
||||
label: 'Photos',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.video,
|
||||
icon: LucideIcons.film,
|
||||
label: 'Videos',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
@@ -559,26 +564,18 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showHidden,
|
||||
onChanged: (_) => photos.toggleShowHidden(),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
HiddenFilesFilterRow(
|
||||
value: photos.hiddenFilter,
|
||||
onChanged: photos.setHiddenFilter,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart';
|
||||
class BottomNavBar extends StatelessWidget {
|
||||
final NooNavStyle style;
|
||||
final NooBottomBarStyle barStyle;
|
||||
final bool frosted;
|
||||
final List<AppTab> tabs;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
@@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.style,
|
||||
this.barStyle = NooBottomBarStyle.attached,
|
||||
this.frosted = false,
|
||||
required this.tabs,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
@@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget {
|
||||
return NooBottomBar(
|
||||
style: style,
|
||||
barStyle: barStyle,
|
||||
frosted: frosted,
|
||||
destinations: [
|
||||
for (final tab in tabs)
|
||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_chip.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
import 'noo/core/noo_toggle.dart';
|
||||
import 'filter_mode_rows.dart';
|
||||
import 'noo/lists/noo_grouped_list.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/overlays/noo_sheet.dart';
|
||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
||||
final files = context.watch<FilesController>();
|
||||
final filtersActive =
|
||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||
files.showHiddenFiles ||
|
||||
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||
|
||||
return SizedBox(
|
||||
@@ -171,54 +171,46 @@ class FilesControlsRow extends StatelessWidget {
|
||||
// Same "icon always, label only when selected" pill as
|
||||
// Photos' own type filter below - not a checkmark list, so
|
||||
// the two screens' filter sheets look and behave the same.
|
||||
NooSegmentedControl<FilesTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: files.filesTypeFilter,
|
||||
onChanged: files.setFilesTypeFilter,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.filesOnly,
|
||||
icon: LucideIcons.file,
|
||||
label: 'Files',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.foldersOnly,
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Folders',
|
||||
),
|
||||
],
|
||||
FilterSection(
|
||||
icon: LucideIcons.layoutGrid,
|
||||
title: 'Type',
|
||||
child: NooSegmentedControl<FilesTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: files.filesTypeFilter,
|
||||
onChanged: files.setFilesTypeFilter,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.filesOnly,
|
||||
icon: LucideIcons.file,
|
||||
label: 'Files',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.foldersOnly,
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Folders',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: files.showHiddenFiles,
|
||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
||||
),
|
||||
),
|
||||
if (showStorageScope)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
HiddenFilesFilterRow(
|
||||
value: files.hiddenFilter,
|
||||
onChanged: files.setHiddenFilter,
|
||||
),
|
||||
if (showStorageScope) ...[
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,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 '../../../theme/design_tokens.dart';
|
||||
import 'noo_nav_style.dart';
|
||||
@@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8;
|
||||
const double _kAndroidPillWidth = 56;
|
||||
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
|
||||
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
||||
/// 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,
|
||||
/// 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
|
||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||
/// [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
|
||||
/// `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'
|
||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||
@@ -54,6 +70,7 @@ const double _kAndroidPillHeight = 32;
|
||||
class NooBottomBar extends StatelessWidget {
|
||||
final NooNavStyle style;
|
||||
final NooBottomBarStyle barStyle;
|
||||
final bool frosted;
|
||||
final List<NooNavDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
@@ -64,6 +81,7 @@ class NooBottomBar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.style,
|
||||
this.barStyle = NooBottomBarStyle.attached,
|
||||
this.frosted = false,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
@@ -75,10 +93,18 @@ class NooBottomBar extends StatelessWidget {
|
||||
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||
/// much clearance (see the class doc comment) instead of guessing.
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||
static const double floatingBottomMargin = 12;
|
||||
@@ -86,8 +112,10 @@ class NooBottomBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final ios = style == NooNavStyle.ios;
|
||||
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;
|
||||
// Attached folds Search into the row itself (last item); floating
|
||||
// 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);
|
||||
|
||||
if (floating) {
|
||||
final pill = Container(
|
||||
final pillRadius = BorderRadius.circular(28);
|
||||
final pillBox = Container(
|
||||
height: barHeight,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
color: _fill(colors),
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
borderRadius: pillRadius,
|
||||
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||
),
|
||||
child: row,
|
||||
);
|
||||
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
@@ -145,6 +175,7 @@ class NooBottomBar extends StatelessWidget {
|
||||
destination: searchDestination!,
|
||||
onTap: onSearchTap!,
|
||||
size: barHeight,
|
||||
frosted: frosted,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -154,9 +185,9 @@ class NooBottomBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
final bar = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
color: _fill(colors),
|
||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||
),
|
||||
child: SafeArea(
|
||||
@@ -164,8 +195,13 @@ class NooBottomBar extends StatelessWidget {
|
||||
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}) {
|
||||
return Row(
|
||||
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
|
||||
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||
/// 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 VoidCallback onTap;
|
||||
final double size;
|
||||
final bool frosted;
|
||||
|
||||
const _SearchSatellite({
|
||||
required this.destination,
|
||||
required this.onTap,
|
||||
required this.size,
|
||||
required this.frosted,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -271,21 +319,28 @@ class _SearchSatellite extends StatelessWidget {
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
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),
|
||||
),
|
||||
child: _satelliteBody(colors),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
children: [
|
||||
_ThemeRow(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),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.search,
|
||||
|
||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||
/// Scaffold already shrinks the body above them - this is then pure
|
||||
/// 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
|
||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||
double bottomBarClearance(BuildContext context) {
|
||||
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(
|
||||
NooLayout.navStyle(context),
|
||||
NooBottomBarStyle.floating,
|
||||
barStyle,
|
||||
);
|
||||
final margin = barStyle == NooBottomBarStyle.floating
|
||||
? NooBottomBar.floatingBottomMargin
|
||||
: 0;
|
||||
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 -
|
||||
|
||||
@@ -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', () {
|
||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||
tester,
|
||||
|
||||
@@ -25,6 +25,69 @@ void main() {
|
||||
setUpNooTests();
|
||||
|
||||
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', (
|
||||
tester,
|
||||
theme,
|
||||
|
||||