Redesign the selection action bar to remove horizontal scrolling
The multi-select action bar (Files/Photos/Favorites) hid bulk actions behind a horizontally-scrolling row with no visible cue that there was more to find, once there were more than a handful (up to 9: favorite, share, download, delete, copy, move, rename, sync, details). Designed the replacement on the linked design canvas first (Selection action bar boards), then implemented it: NooSelectionBar now shows a fixed number of actions inline (3 on mobile, 4 on desktop) with the rest behind a trailing "More" button that opens the same overflow sheet a file row's own "..." menu already uses. Removed the now-dead scroll-hint plumbing from the three screens that render it.
This commit is contained in:
+27
-134
@@ -21,6 +21,7 @@ import '../widgets/noo/files/noo_file_row.dart';
|
||||
import '../widgets/noo/files/noo_file_table.dart';
|
||||
import '../widgets/noo/files/noo_file_tile.dart';
|
||||
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||
import '../widgets/noo/media/noo_grid_card.dart';
|
||||
import '../widgets/noo/noo_layout.dart';
|
||||
@@ -55,92 +56,23 @@ class FavoritesView extends StatefulWidget {
|
||||
State<FavoritesView> createState() => _FavoritesViewState();
|
||||
}
|
||||
|
||||
class _FavoritesViewState extends State<FavoritesView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _FavoritesViewState extends State<FavoritesView> {
|
||||
bool _requested = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
||||
final List<AnimationController> _scrollHintControllers = [];
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
|
||||
void _toggleSelection(NextcloudItem item) {
|
||||
HapticFeedback.selectionClick();
|
||||
final enteringSelection = _selectedIds.isEmpty;
|
||||
setState(() {
|
||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||
});
|
||||
if (enteringSelection && _isSelecting) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _playScrollHint(_selectionActionsScrollController),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(_selectedIds.clear);
|
||||
}
|
||||
|
||||
/// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their
|
||||
/// doc comment: nudges the selection actions row right and back, once,
|
||||
/// the first time a selection starts. No-ops if there's nothing to
|
||||
/// scroll (row already fits).
|
||||
Future<void> _playScrollHint(ScrollController scrollController) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// `hasClients` only means a position is attached, not that it's
|
||||
// finished its first layout - reading maxScrollExtent before that
|
||||
// throws (min/maxScrollExtent are still null internally).
|
||||
if (!mounted ||
|
||||
!scrollController.hasClients ||
|
||||
!scrollController.position.hasContentDimensions) {
|
||||
return;
|
||||
}
|
||||
final maxExtent = scrollController.position.maxScrollExtent;
|
||||
if (maxExtent <= 0) return;
|
||||
final double peak = maxExtent < 36 ? maxExtent : 36;
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
_scrollHintControllers.add(controller);
|
||||
final hint = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: 0.0,
|
||||
end: peak,
|
||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
||||
weight: 50,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: peak,
|
||||
end: 0.0,
|
||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
||||
weight: 50,
|
||||
),
|
||||
]).animate(controller);
|
||||
void onTick() {
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.jumpTo(hint.value);
|
||||
}
|
||||
}
|
||||
|
||||
hint.addListener(onTick);
|
||||
await controller.forward();
|
||||
hint.removeListener(onTick);
|
||||
_scrollHintControllers.remove(controller);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _scrollHintControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
_selectionActionsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A favorited folder switches to the Files tab, navigated there; a
|
||||
/// favorited file just opens directly from here, like Files/Photos do -
|
||||
/// no need to reposition Files first since dismissing the viewer lands
|
||||
@@ -166,40 +98,40 @@ class _FavoritesViewState extends State<FavoritesView>
|
||||
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
|
||||
return [
|
||||
SelectionAction(
|
||||
icon: Icons.favorite_border_rounded,
|
||||
icon: LucideIcons.starOff,
|
||||
label: 'Remove from favorites',
|
||||
onTap: () => _unfavoriteSelected(selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.share_rounded,
|
||||
icon: LucideIcons.share2,
|
||||
label: 'Share',
|
||||
onTap: () => selected.length == 1
|
||||
? ShareSheet.show(context, selected.single)
|
||||
: _shareSelected(context, selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.download_rounded,
|
||||
icon: LucideIcons.download,
|
||||
label: 'Download',
|
||||
onTap: () => _downloadSelected(context, selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Delete',
|
||||
onTap: () => _confirmDeleteSelected(context, selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.copy_rounded,
|
||||
icon: LucideIcons.copy,
|
||||
label: 'Copy',
|
||||
onTap: () => _moveOrCopySelected(selected, copy: true),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.drive_file_move_rounded,
|
||||
icon: LucideIcons.folderInput,
|
||||
label: 'Move',
|
||||
onTap: () => _moveOrCopySelected(selected, copy: false),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: Icons.info_outline_rounded,
|
||||
icon: LucideIcons.info,
|
||||
label: 'Details',
|
||||
onTap: () => DetailsSheet.show(context, selected.single),
|
||||
),
|
||||
@@ -477,7 +409,11 @@ class _FavoritesViewState extends State<FavoritesView>
|
||||
col3: item.isFolder ? null : formatBytes(item.size),
|
||||
favorite: item.isFavorite,
|
||||
selected: isSelected,
|
||||
thumbnail: _thumbnailFor(context, item, extent: NooFileTileSize.desktop.extent),
|
||||
thumbnail: _thumbnailFor(
|
||||
context,
|
||||
item,
|
||||
extent: NooFileTileSize.desktop.extent,
|
||||
),
|
||||
onTap: () {
|
||||
if (_isSelecting) {
|
||||
_toggleSelection(item);
|
||||
@@ -639,7 +575,12 @@ class _FavoritesViewState extends State<FavoritesView>
|
||||
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return _buildGridCard(context, favorites[index], siblings, isDesktop);
|
||||
return _buildGridCard(
|
||||
context,
|
||||
favorites[index],
|
||||
siblings,
|
||||
isDesktop,
|
||||
);
|
||||
}, childCount: favorites.length),
|
||||
),
|
||||
)
|
||||
@@ -673,11 +614,7 @@ class _FavoritesViewState extends State<FavoritesView>
|
||||
},
|
||||
);
|
||||
}
|
||||
return _buildTableRow(
|
||||
context,
|
||||
favorites[index - 1],
|
||||
siblings,
|
||||
);
|
||||
return _buildTableRow(context, favorites[index - 1], siblings);
|
||||
}, childCount: favorites.length + 1),
|
||||
),
|
||||
)
|
||||
@@ -719,56 +656,12 @@ class _FavoritesViewState extends State<FavoritesView>
|
||||
BuildContext context,
|
||||
List<NextcloudItem> selectedItems,
|
||||
) {
|
||||
final colors = context.nooColors;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${selectedItems.length} selected',
|
||||
style: NooText.cardTitle.copyWith(
|
||||
fontSize: 17,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _selectionActionsScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final action in _buildSelectionActions(selectedItems))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, size: 20, color: colors.fg1),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return NooSelectionBar(
|
||||
count: selectedItems.length,
|
||||
actions: _buildSelectionActions(selectedItems),
|
||||
onClose: _clearSelection,
|
||||
isDesktop: NooLayout.isDesktop(context),
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+47
-86
@@ -35,6 +35,7 @@ import '../widgets/noo/files/noo_file_tile.dart';
|
||||
import '../widgets/noo/files/noo_status_icon.dart';
|
||||
import '../widgets/noo/files/noo_swipe_action.dart';
|
||||
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||
import '../widgets/noo/lists/noo_summary_card.dart';
|
||||
import '../widgets/noo/media/noo_grid_card.dart';
|
||||
@@ -231,7 +232,6 @@ class _FilesViewState extends State<FilesView>
|
||||
final Set<String> _selectedIds = {};
|
||||
int _lastPathDepth = 1;
|
||||
final ScrollController _controlsScrollController = ScrollController();
|
||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
||||
final List<AnimationController> _scrollHintControllers = [];
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
@@ -247,9 +247,8 @@ class _FilesViewState extends State<FilesView>
|
||||
|
||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
||||
/// thumbnails come from server previews instead.
|
||||
File? _localFileFor(BuildContext context, NextcloudItem item) => _offline
|
||||
? context.read<OfflineController>().localFileFor(item)
|
||||
: null;
|
||||
File? _localFileFor(BuildContext context, NextcloudItem item) =>
|
||||
_offline ? context.read<OfflineController>().localFileFor(item) : null;
|
||||
|
||||
// (Online only - the Offline tab always reloads on first build, since a
|
||||
// local listing has no cheap "already loaded" signal.)
|
||||
@@ -340,21 +339,14 @@ class _FilesViewState extends State<FilesView>
|
||||
controller.dispose();
|
||||
}
|
||||
_controlsScrollController.dispose();
|
||||
_selectionActionsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleSelection(NextcloudItem item) {
|
||||
HapticFeedback.selectionClick();
|
||||
final enteringSelection = _selectedIds.isEmpty;
|
||||
setState(() {
|
||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||
});
|
||||
if (enteringSelection && _isSelecting) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _playScrollHint(_selectionActionsScrollController),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
@@ -766,7 +758,9 @@ class _FilesViewState extends State<FilesView>
|
||||
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_offline ? 'Could not read local files' : 'WebDAV Sync Error',
|
||||
_offline
|
||||
? 'Could not read local files'
|
||||
: 'WebDAV Sync Error',
|
||||
style: NooText.cardTitle.copyWith(color: colors.danger),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -814,7 +808,12 @@ class _FilesViewState extends State<FilesView>
|
||||
else if (files.isGridView)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-grid'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, NooSpace.lg),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop ? 5 : 2,
|
||||
@@ -838,7 +837,11 @@ class _FilesViewState extends State<FilesView>
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(context, files, browser.currentFolderPath),
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
@@ -872,7 +875,12 @@ class _FilesViewState extends State<FilesView>
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(context, item, index, browser.items.length),
|
||||
child: _buildMobileRow(
|
||||
context,
|
||||
item,
|
||||
index,
|
||||
browser.items.length,
|
||||
),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
@@ -955,7 +963,9 @@ class _FilesViewState extends State<FilesView>
|
||||
stat: stat,
|
||||
caption: caption,
|
||||
meta: meta,
|
||||
tone: hasConflicts ? NooSummaryCardTone.danger : NooSummaryCardTone.normal,
|
||||
tone: hasConflicts
|
||||
? NooSummaryCardTone.danger
|
||||
: NooSummaryCardTone.normal,
|
||||
action: NooButton(
|
||||
variant: NooButtonVariant.tonal,
|
||||
size: NooButtonSize.compact,
|
||||
@@ -968,75 +978,20 @@ class _FilesViewState extends State<FilesView>
|
||||
);
|
||||
}
|
||||
|
||||
/// Takes over the controls row's own sticky slot while selecting - a
|
||||
/// close button, the "N selected" count, and the horizontally-scrollable
|
||||
/// bulk actions (in accent-text, per `DESIGN_SYSTEM.md`'s pinned
|
||||
/// selection bar).
|
||||
/// Takes over the controls row's own sticky slot while selecting - see
|
||||
/// `NooSelectionBar` (design canvas
|
||||
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
|
||||
/// bar" - DESIGN_SYSTEM.md has no §4 recipe of its own for this).
|
||||
Widget _buildSelectionBar(
|
||||
BuildContext context,
|
||||
List<NextcloudItem> selectedItems,
|
||||
) {
|
||||
final colors = context.nooColors;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${selectedItems.length} selected',
|
||||
style: NooText.cardTitle.copyWith(
|
||||
fontSize: 17,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _selectionActionsScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
// Left-aligned (not anchored to the trailing edge) so the
|
||||
// first action's left edge sits at a fixed spot - lining up
|
||||
// with the controls row's own first icon directly below it -
|
||||
// regardless of how many actions there are.
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final action in _buildSelectionActions(
|
||||
context,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
action.icon,
|
||||
size: 20,
|
||||
color: colors.accentText,
|
||||
),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return NooSelectionBar(
|
||||
count: selectedItems.length,
|
||||
actions: _buildSelectionActions(context, selectedItems),
|
||||
onClose: _clearSelection,
|
||||
isDesktop: NooLayout.isDesktop(context),
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1144,9 +1099,7 @@ class _FilesViewState extends State<FilesView>
|
||||
// just some)? Save straight from the local copy instead of a fresh
|
||||
// network fetch through DownloadService - see
|
||||
// SyncStatusController.localSyncedFilePath.
|
||||
final localPaths = await Future.wait(
|
||||
files.map(sync.localSyncedFilePath),
|
||||
);
|
||||
final localPaths = await Future.wait(files.map(sync.localSyncedFilePath));
|
||||
if (localPaths.every((path) => path != null)) {
|
||||
try {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
@@ -1355,7 +1308,11 @@ class _FilesViewState extends State<FilesView>
|
||||
FilesController files,
|
||||
String folderPath,
|
||||
) {
|
||||
const columns = [FileSortField.name, FileSortField.size, FileSortField.dateModified];
|
||||
const columns = [
|
||||
FileSortField.name,
|
||||
FileSortField.size,
|
||||
FileSortField.dateModified,
|
||||
];
|
||||
final field = files.sortFieldFor(folderPath);
|
||||
final sortColumn = columns.indexOf(field);
|
||||
return NooFileTableHeader(
|
||||
@@ -1486,7 +1443,11 @@ class _FilesViewState extends State<FilesView>
|
||||
item: item,
|
||||
service: session.service,
|
||||
localFile: _localFileFor(context, item),
|
||||
fallback: Icon(kind.icon, color: kind.foreground(colors), size: 32),
|
||||
fallback: Icon(
|
||||
kind.icon,
|
||||
color: kind.foreground(colors),
|
||||
size: 32,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
|
||||
|
||||
+107
-208
@@ -20,6 +20,7 @@ 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';
|
||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||
import '../widgets/noo/media/noo_photo_group.dart';
|
||||
import '../widgets/noo/media/noo_photo_tile.dart';
|
||||
@@ -57,12 +58,9 @@ class PhotosView extends StatefulWidget {
|
||||
State<PhotosView> createState() => _PhotosViewState();
|
||||
}
|
||||
|
||||
class _PhotosViewState extends State<PhotosView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _PhotosViewState extends State<PhotosView> {
|
||||
bool _requested = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
||||
final List<AnimationController> _scrollHintControllers = [];
|
||||
|
||||
// Session-local only (not one of PhotosController's persisted display
|
||||
// prefs) - a pure narrowing of the already-fetched/filtered/sorted list,
|
||||
@@ -74,81 +72,15 @@ class _PhotosViewState extends State<PhotosView>
|
||||
|
||||
void _toggleSelection(NextcloudItem item) {
|
||||
HapticFeedback.selectionClick();
|
||||
final enteringSelection = _selectedIds.isEmpty;
|
||||
setState(() {
|
||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||
});
|
||||
if (enteringSelection && _isSelecting) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _playScrollHint(_selectionActionsScrollController),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(_selectedIds.clear);
|
||||
}
|
||||
|
||||
/// A one-shot hint that the selection actions row actually scrolls -
|
||||
/// mirrors `FilesView`'s identical controls-row hint (see its doc
|
||||
/// comment): nudges it right and back, once, the first time a selection
|
||||
/// starts. No-ops if there's nothing to scroll (row already fits).
|
||||
Future<void> _playScrollHint(ScrollController scrollController) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// `hasClients` only means a position is attached, not that it's
|
||||
// finished its first layout - reading maxScrollExtent before that
|
||||
// throws (min/maxScrollExtent are still null internally).
|
||||
if (!mounted ||
|
||||
!scrollController.hasClients ||
|
||||
!scrollController.position.hasContentDimensions) {
|
||||
return;
|
||||
}
|
||||
final maxExtent = scrollController.position.maxScrollExtent;
|
||||
if (maxExtent <= 0) return;
|
||||
final double peak = maxExtent < 36 ? maxExtent : 36;
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
_scrollHintControllers.add(controller);
|
||||
final hint = TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: 0.0,
|
||||
end: peak,
|
||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
||||
weight: 50,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: peak,
|
||||
end: 0.0,
|
||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
||||
weight: 50,
|
||||
),
|
||||
]).animate(controller);
|
||||
void onTick() {
|
||||
if (scrollController.hasClients) {
|
||||
scrollController.jumpTo(hint.value);
|
||||
}
|
||||
}
|
||||
|
||||
hint.addListener(onTick);
|
||||
await controller.forward();
|
||||
hint.removeListener(onTick);
|
||||
_scrollHintControllers.remove(controller);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _scrollHintControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
_selectionActionsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
||||
/// just the matching/toggle/immediate-confirm branch.
|
||||
void _handlePickTap(
|
||||
@@ -181,52 +113,52 @@ class _PhotosViewState extends State<PhotosView>
|
||||
if (pick.isPicking) {
|
||||
return [
|
||||
SelectionAction(
|
||||
icon: Icons.check_rounded,
|
||||
icon: LucideIcons.check,
|
||||
label: 'Use ${selected.length} item(s)',
|
||||
onTap: () => pick.confirmPick(selected),
|
||||
),
|
||||
];
|
||||
}
|
||||
// Matches the star/star-off convention Files' own selection toolbar
|
||||
// uses for favorite/unfavorite (see `files_view.dart`) rather than a
|
||||
// filled heart.
|
||||
final allFavorited = selected.every((i) => i.isFavorite);
|
||||
return [
|
||||
SelectionAction(
|
||||
icon: selected.every((i) => i.isFavorite)
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
label: selected.every((i) => i.isFavorite)
|
||||
? 'Remove from favorites'
|
||||
: 'Favorite',
|
||||
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
|
||||
label: allFavorited ? 'Remove from favorites' : 'Favorite',
|
||||
onTap: () => _favoriteSelected(selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.share_rounded,
|
||||
icon: LucideIcons.share2,
|
||||
label: 'Share',
|
||||
onTap: () => selected.length == 1
|
||||
? ShareSheet.show(context, selected.single)
|
||||
: _shareSelected(context, selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.download_rounded,
|
||||
icon: LucideIcons.download,
|
||||
label: 'Download',
|
||||
onTap: () => _downloadSelected(context, selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
icon: LucideIcons.trash2,
|
||||
label: 'Delete',
|
||||
onTap: () => _confirmDeleteSelected(context, selected),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.copy_rounded,
|
||||
icon: LucideIcons.copy,
|
||||
label: 'Copy',
|
||||
onTap: () => _moveOrCopySelected(selected, copy: true),
|
||||
),
|
||||
SelectionAction(
|
||||
icon: Icons.drive_file_move_rounded,
|
||||
icon: LucideIcons.folderInput,
|
||||
label: 'Move',
|
||||
onTap: () => _moveOrCopySelected(selected, copy: false),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: Icons.info_outline_rounded,
|
||||
icon: LucideIcons.info,
|
||||
label: 'Details',
|
||||
onTap: () => DetailsSheet.show(context, selected.single),
|
||||
),
|
||||
@@ -462,15 +394,13 @@ class _PhotosViewState extends State<PhotosView>
|
||||
const SizedBox(width: 8),
|
||||
NooChip(
|
||||
selected: _typeFilter == _PhotoTypeFilter.image,
|
||||
onTap: () =>
|
||||
setState(() => _typeFilter = _PhotoTypeFilter.image),
|
||||
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.image),
|
||||
child: const Text('Photos'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
NooChip(
|
||||
selected: _typeFilter == _PhotoTypeFilter.video,
|
||||
onTap: () =>
|
||||
setState(() => _typeFilter = _PhotoTypeFilter.video),
|
||||
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.video),
|
||||
child: const Text('Videos'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -491,43 +421,58 @@ class _PhotosViewState extends State<PhotosView>
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooSegmentedControl<bool>(
|
||||
fill: true,
|
||||
value: photos.sortAscending,
|
||||
onChanged: (asc) {
|
||||
if (asc != photos.sortAscending) photos.toggleSortOrder();
|
||||
},
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: true,
|
||||
icon: LucideIcons.arrowUp,
|
||||
label: 'Ascending',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: false,
|
||||
icon: LucideIcons.arrowDown,
|
||||
label: 'Descending',
|
||||
),
|
||||
],
|
||||
),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
for (final field in FileSortField.values)
|
||||
NooSettingsRow(
|
||||
label: Text(sortFieldLabel(field)),
|
||||
trailing: field == photos.sortField
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
photos.setSortField(field);
|
||||
Navigator.pop(context);
|
||||
// `showNooSheet`'s `children` are built once, up front - a bare
|
||||
// checkmark here would freeze at whatever it was when the sheet
|
||||
// opened, since tapping a row calls `photos.set...`/notifies the
|
||||
// controller, not this already-built widget tree. `ListenableBuilder`
|
||||
// re-runs its `builder` on every notification instead, so the
|
||||
// selection updates live.
|
||||
ListenableBuilder(
|
||||
listenable: photos,
|
||||
builder: (context, _) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
NooSegmentedControl<bool>(
|
||||
fill: true,
|
||||
value: photos.sortAscending,
|
||||
onChanged: (asc) {
|
||||
if (asc != photos.sortAscending) photos.toggleSortOrder();
|
||||
},
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: true,
|
||||
icon: LucideIcons.arrowUp,
|
||||
label: 'Ascending',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: false,
|
||||
icon: LucideIcons.arrowDown,
|
||||
label: 'Descending',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
for (final field in FileSortField.values)
|
||||
NooSettingsRow(
|
||||
label: Text(sortFieldLabel(field)),
|
||||
trailing: field == photos.sortField
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.nooColors.accentText,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
photos.setSortField(field);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -541,105 +486,59 @@ class _PhotosViewState extends State<PhotosView>
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.heart,
|
||||
label: const Text('Favorites only'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showFavoritesOnly,
|
||||
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,
|
||||
// See `_showSortSheet`'s comment - `Listenable.merge` since these
|
||||
// toggles span two controllers.
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([photos, files]),
|
||||
builder: (context, _) => NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.heart,
|
||||
label: const Text('Favorites only'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showFavoritesOnly,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Replaces the controls row's own sticky slot while selecting - a close
|
||||
/// button, the "N selected" count, and the horizontally-scrollable bulk
|
||||
/// actions. Mirrors `FilesView`'s identical selection bar.
|
||||
/// button, the "N selected" count, and the bulk actions. Mirrors
|
||||
/// `FilesView`'s identical selection bar.
|
||||
Widget _buildSelectionBar(
|
||||
BuildContext context,
|
||||
PickController pick,
|
||||
List<NextcloudItem> selectedItems,
|
||||
) {
|
||||
final colors = context.nooColors;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(LucideIcons.x, size: 20, color: colors.fg1),
|
||||
tooltip: 'Cancel selection',
|
||||
onPressed: _clearSelection,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${selectedItems.length} selected',
|
||||
style: NooText.cardTitle.copyWith(
|
||||
fontSize: 17,
|
||||
color: colors.fg1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _selectionActionsScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
// Left-aligned (not anchored to the trailing edge) so the
|
||||
// first action's left edge sits at a fixed spot - lining up
|
||||
// with the controls row's own first chip directly below it -
|
||||
// regardless of how many actions there are.
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final action in _buildSelectionActions(
|
||||
pick,
|
||||
selectedItems,
|
||||
))
|
||||
IconButton(
|
||||
icon: Icon(action.icon, size: 20, color: colors.fg1),
|
||||
tooltip: action.label,
|
||||
onPressed: action.onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return NooSelectionBar(
|
||||
count: selectedItems.length,
|
||||
actions: _buildSelectionActions(pick, selectedItems),
|
||||
onClose: _clearSelection,
|
||||
isDesktop: NooLayout.isDesktop(context),
|
||||
iosStyle: NooLayout.iosStyle(context),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user