Author SHA1 Message Date
Ayushya AmitabhandClaude Sonnet 5.5 813c613249 Dynamic color: keep card surfaces lighter than the page
Build APK / build (push) Successful in 5m39s
In light mode surfaceContainerLow is slightly darker than the OS surface bg
maps to, so the selection bar and other cards blended into the page. Use
surfaceContainerLowest (light) / surfaceContainer (dark) for NooColors.surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:53:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 c0a67f3efe iOS: replace default Flutter app icons with the Noo icon
Generated from assets/icon/app_icon.png, opaque RGB at every size in the set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:42:30 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5fbac333d5 Filter sheets: 'Type' label above the type control; shorten 'All + ...' to 'All'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:38:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 3c60f8c709 Filters: three-way hidden files and external storage
Hidden: hide / only hidden / all + hidden. External: cloud only / only
external / all + external; with 'all' Files shows the external items in a
collapsible section. Old show-hidden bool prefs are still read as fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:34:11 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 944b87b054 Bottom bar: add frosted glass option (Settings > Appearance)
Translucent fill over a backdrop blur, both platforms and both bar styles.
Frosted bars draw over an extended body, so the shell's extendBody and the
tab clearance now key off NooBottomBar.drawsBehindBody.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:16:27 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d6448984d9 Files: don't context.watch from the item tap handler
watch outside build asserts in debug builds, so taps on rows silently did
nothing in debug (release strips the assert, which hid it on Android).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:13:45 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 52abd16983 Floating bottom bar: use Android row (icon-only idle tabs) on iOS too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 21:36:35 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 cb66b4a0ad iOS: raise deployment target to 15.0 (required by current Flutter)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 20:38:17 -04:00
ayushyaandClaude Code da3b8a4f05 README: explain license choice and Gitea source of truth
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:34:08 -04:00
35 changed files with 997 additions and 250 deletions
+31 -1
View File
@@ -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
+20
View File
@@ -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.
+3 -3
View File
@@ -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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+8 -3
View File
@@ -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,
+66 -26
View File
@@ -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 {
+16 -9
View File
@@ -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),
);
}
+12
View File
@@ -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;
+11 -2
View File
@@ -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,
+202 -90
View File
@@ -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)),
),
+42 -45
View File
@@ -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,
),
],
),
),
+3
View File
@@ -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),
+39 -47
View File
@@ -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,
),
],
],
),
),
+155
View File
@@ -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,
),
);
}
}
+75 -20
View File
@@ -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,
+11 -4
View File
@@ -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 -
+129
View File
@@ -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);
});
});
}
+76
View File
@@ -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);
});
});
}
+24
View File
@@ -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,
+63
View File
@@ -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,