Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s

This commit is contained in:
Ayushya Amitabh
2026-10-06 23:18:50 -04:00
parent c5b46fd493
commit ce2273c9a6
39 changed files with 2309 additions and 647 deletions
+54 -28
View File
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/selection_bar_overlay.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart';
@@ -54,8 +55,14 @@ class FavoritesView extends StatefulWidget {
/// This tab's own shell top bar, planted as its first sliver - see
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
final PreferredSizeWidget? topBar;
final ValueChanged<bool>? onSelectionChanged;
const FavoritesView({super.key, required this.scrollController, this.topBar});
const FavoritesView({
super.key,
required this.scrollController,
this.topBar,
this.onSelectionChanged,
});
@override
State<FavoritesView> createState() => _FavoritesViewState();
@@ -72,10 +79,12 @@ class _FavoritesViewState extends State<FavoritesView> {
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
widget.onSelectionChanged?.call(_isSelecting);
}
void _clearSelection() {
setState(_selectedIds.clear);
widget.onSelectionChanged?.call(false);
}
/// A favorited folder switches to the Files tab, navigated there; a
@@ -483,6 +492,10 @@ class _FavoritesViewState extends State<FavoritesView> {
@override
Widget build(BuildContext context) {
final selectionAtBottom =
_isSelecting &&
context.watch<SettingsController>().selectionBarPosition ==
SelectionBarPosition.bottom;
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final favoritesController = context.watch<FavoritesController>();
@@ -513,15 +526,17 @@ class _FavoritesViewState extends State<FavoritesView> {
final contentSlivers = <Widget>[
if (widget.topBar != null) topBarSliver(widget.topBar!),
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, selectedItems)
: controlsRow,
if (!selectionAtBottom)
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
floating: _isSelecting,
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, selectedItems)
: controlsRow,
),
),
),
if (favoritesController.isLoading && favorites.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
@@ -646,7 +661,13 @@ class _FavoritesViewState extends State<FavoritesView> {
}, childCount: favorites.length),
),
),
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
SliverToBoxAdapter(
child: SizedBox(
height:
bottomBarClearance(context) +
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
),
),
];
return PopScope(
@@ -654,24 +675,29 @@ class _FavoritesViewState extends State<FavoritesView> {
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating
// top bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
child: SelectionBarOverlay(
bar: selectionAtBottom
? _buildSelectionBar(context, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: favoritesController.fetchAll,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating
// top bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
),
+1 -3
View File
@@ -428,9 +428,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
// The stage follows the app's own theme (`bg`) rather than being pinned
// to black - a black stage in light mode read as jarringly out of place
// next to the rest of the light-themed app. The floating top/action
// bars now follow the theme too (FrostedGlassContainer's own `surface`
// default), at a higher blur opacity to stay legible over arbitrary
// photo/video brightness underneath.
// bars follow the theme and share the navigation frost preference.
final stageColor = colors.bg;
return Scaffold(
+69 -49
View File
@@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart';
import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sticky_header_delegate.dart';
import '../widgets/selection_bar_overlay.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import '../widgets/synced_header_scaffold.dart' show formatBytes;
import 'file_viewer_screen.dart';
@@ -64,12 +65,14 @@ class FilesView extends StatefulWidget {
/// This tab's own shell top bar, planted as its first sliver - see
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
final PreferredSizeWidget? topBar;
final ValueChanged<bool>? onSelectionChanged;
const FilesView({
super.key,
required this.scrollController,
this.offline = false,
this.topBar,
this.onSelectionChanged,
});
@override
@@ -337,10 +340,12 @@ class _FilesViewState extends State<FilesView> {
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
widget.onSelectionChanged?.call(_isSelecting);
}
void _clearSelection() {
setState(_selectedIds.clear);
widget.onSelectionChanged?.call(false);
}
/// Routes a tap on an item, in picking, selecting or plain-browsing mode
@@ -652,6 +657,10 @@ class _FilesViewState extends State<FilesView> {
@override
Widget build(BuildContext context) {
final selectionAtBottom =
_isSelecting &&
context.watch<SettingsController>().selectionBarPosition ==
SelectionBarPosition.bottom;
final colors = context.nooColors;
// Display prefs (grid/list, filters, sort) live on FilesController for
// both tabs; `browser` is the tab's own folder listing.
@@ -835,8 +844,7 @@ class _FilesViewState extends State<FilesView> {
child: _ExternalStorageHeader(
count: externalItems.length,
expanded: _externalExpanded,
onTap: () =>
setState(() => _externalExpanded = !_externalExpanded),
onTap: () => setState(() => _externalExpanded = !_externalExpanded),
),
),
if (_externalExpanded)
@@ -854,17 +862,19 @@ class _FilesViewState extends State<FilesView> {
// (+ breadcrumbs), while selecting it's the selection bar (see
// `topRow` above): either way it's the one thing that always stays
// at the very top of the scroll view.
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
// Getting this wrong overflows the sliver header by exactly the
// shortfall - a real bug this shipped with once already.
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
child: topRow,
if (!selectionAtBottom)
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
floating: _isSelecting,
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
// Getting this wrong overflows the sliver header by exactly the
// shortfall - a real bug this shipped with once already.
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
child: topRow,
),
),
),
if (_offline)
SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)),
// Files List / Grid
@@ -939,12 +949,17 @@ class _FilesViewState extends State<FilesView> {
// 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,
))
if (selectionAtBottom ||
NooBottomBar.drawsBehindBody(
context.watch<SettingsController>().bottomBarStyle,
context.watch<SettingsController>().bottomBarFrosted,
))
SliverToBoxAdapter(
child: SizedBox(height: bottomBarClearance(context)),
child: SizedBox(
height:
bottomBarClearance(context) +
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
),
),
];
@@ -958,38 +973,43 @@ class _FilesViewState extends State<FilesView> {
browser.navigateUp();
}
},
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () {
unawaited(sync.syncOnPull());
return browser.reload();
},
// `topBarSliver`'s floating header can collapse all the way to
// zero height (fully scrolled away), at which point the sticky
// controls row right below it in `contentSlivers` would otherwise
// ride up underneath the status bar instead of stopping below it
// - the floating top bar used to be the only thing reserving that
// space (via its own internal `SafeArea`), and that reservation
// disappears along with it once it's fully hidden. Wrapping the
// whole scroll view keeps the inset outside the scrolling region
// entirely, so it's never implicated in the floating header's own
// collapse/reveal math - safe to apply unconditionally, since
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
// status bar there).
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// Pull-to-refresh needs a scroll physics that allows dragging
// past the edge even when content doesn't fill the viewport -
// an empty or single-item list otherwise can't be pulled at
// all under the platform default physics.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
child: SelectionBarOverlay(
bar: selectionAtBottom
? _buildSelectionBar(context, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () {
unawaited(sync.syncOnPull());
return browser.reload();
},
// `topBarSliver`'s floating header can collapse all the way to
// zero height (fully scrolled away), at which point the sticky
// controls row right below it in `contentSlivers` would otherwise
// ride up underneath the status bar instead of stopping below it
// - the floating top bar used to be the only thing reserving that
// space (via its own internal `SafeArea`), and that reservation
// disappears along with it once it's fully hidden. Wrapping the
// whole scroll view keeps the inset outside the scrolling region
// entirely, so it's never implicated in the floating header's own
// collapse/reveal math - safe to apply unconditionally, since
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
// status bar there).
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// Pull-to-refresh needs a scroll physics that allows dragging
// past the edge even when content doesn't fill the viewport -
// an empty or single-item list otherwise can't be pulled at
// all under the platform default physics.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
),
+86 -46
View File
@@ -19,7 +19,6 @@ 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';
import '../widgets/noo/core/noo_toggle.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/lists/noo_grouped_list.dart';
import '../widgets/noo/lists/noo_selection_bar.dart';
@@ -31,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
import '../widgets/sticky_header_delegate.dart';
import '../widgets/selection_bar_overlay.dart';
import '../widgets/tabs/tab_state_slivers.dart';
import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
@@ -58,8 +58,14 @@ class PhotosView extends StatefulWidget {
/// This tab's own shell top bar, planted as its first sliver - see
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
final PreferredSizeWidget? topBar;
final ValueChanged<bool>? onSelectionChanged;
const PhotosView({super.key, required this.scrollController, this.topBar});
const PhotosView({
super.key,
required this.scrollController,
this.topBar,
this.onSelectionChanged,
});
@override
State<PhotosView> createState() => _PhotosViewState();
@@ -82,10 +88,12 @@ class _PhotosViewState extends State<PhotosView> {
setState(() {
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
});
widget.onSelectionChanged?.call(_isSelecting);
}
void _clearSelection() {
setState(_selectedIds.clear);
widget.onSelectionChanged?.call(false);
}
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
@@ -215,6 +223,10 @@ class _PhotosViewState extends State<PhotosView> {
@override
Widget build(BuildContext context) {
final selectionAtBottom =
_isSelecting &&
context.watch<SettingsController>().selectionBarPosition ==
SelectionBarPosition.bottom;
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final photosController = context.watch<PhotosController>();
@@ -248,23 +260,25 @@ class _PhotosViewState extends State<PhotosView> {
if (widget.topBar != null) topBarSliver(widget.topBar!),
// Sticky while browsing; once selecting, the selection bar takes over
// the same slot instead.
SliverPersistentHeader(
pinned: !_isSelecting,
delegate: StickyHeaderDelegate(
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, pick, selectedItems)
: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
if (!selectionAtBottom)
SliverPersistentHeader(
pinned: true,
delegate: StickyHeaderDelegate(
floating: _isSelecting,
height: _isSelecting ? 56 : 64,
child: _isSelecting
? _buildSelectionBar(context, pick, selectedItems)
: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.sm,
NooSpace.sm,
NooSpace.sm,
NooSpace.xs,
),
child: controlsRow,
),
child: controlsRow,
),
),
),
),
if (photosController.isLoading && allPhotos.isEmpty)
const SliverFillRemaining(
@@ -360,7 +374,13 @@ class _PhotosViewState extends State<PhotosView> {
// Clearance so the last row isn't hidden behind the nav bar,
// regardless of grid length - see `bottomBarClearance`'s own doc
// comment for why this has to be dynamic rather than a flat 100.
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
SliverToBoxAdapter(
child: SizedBox(
height:
bottomBarClearance(context) +
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
),
),
];
return PopScope(
@@ -368,24 +388,29 @@ class _PhotosViewState extends State<PhotosView> {
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating
// top bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
child: SelectionBarOverlay(
bar: selectionAtBottom
? _buildSelectionBar(context, pick, selectedItems)
: null,
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia,
// See files_view.dart's identical fix - without this, the sticky
// controls row rides up under the status bar once the floating
// top bar above it fully collapses.
child: SafeArea(
top: true,
bottom: false,
child: CustomScrollView(
controller: widget.scrollController,
// See files_view.dart's identical fix - without this, pull-to-
// refresh can't be triggered on an empty or single-item list.
physics: const AlwaysScrollableScrollPhysics(),
slivers: contentSlivers,
),
),
),
),
@@ -554,17 +579,32 @@ class _PhotosViewState extends State<PhotosView> {
),
),
const SizedBox(height: 22),
NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.heart,
label: const Text('Favorites only'),
trailing: NooToggle(
checked: photos.showFavoritesOnly,
onChanged: (_) => photos.toggleFavoritesFilter(),
FilterSection(
icon: LucideIcons.heart,
title: 'Favorites',
child: NooSegmentedControl<bool>(
fill: true,
onSurface: true,
labelOnlySelected: true,
value: photos.showFavoritesOnly,
onChanged: (value) {
if (value != photos.showFavoritesOnly) {
photos.toggleFavoritesFilter();
}
},
options: const [
NooSegmentOption(
value: false,
icon: LucideIcons.layers,
label: 'All',
),
),
],
NooSegmentOption(
value: true,
icon: LucideIcons.heart,
label: 'Favorites only',
),
],
),
),
const SizedBox(height: 22),
HiddenFilesFilterRow(