Files
noo/lib/views/photos_view.dart
T
ayushyaandClaude Opus 5.5 56784ef212 Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/
Instrument Sans, Lucide icons, no gradients/shadows.

- New lib/widgets/noo/ component kit (core, lists, files, media, nav,
  overlays) plus lib/theme/design_tokens.dart for the color/type/space/
  radius/motion tokens.
- Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and
  toolbar), every tab (Files/Offline, Photos, Favorites, Recent,
  Activity, Trash, Shares), Settings, the lock and login screens, the
  media viewer, search, the details/share sheets, and the share-upload
  and move/copy destination pickers.
- Added a design canvas (linked from DESIGN_SYSTEM.md) covering the
  screens the spec didn't already describe, with matching Android and
  iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4.
- Removed now-dead legacy widgets (media_grid_tile, swipeable_item,
  sync_status_badge, selectable_thumbnail) and updated
  architecture.md/styling.md/standards.md to describe the new
  structure and component/testing conventions.
- Added widget tests for the noo/ component kit.

This is a UI-only rework: no provider/model/service behavior changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 01:51:58 -04:00

882 lines
29 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart';
import '../models/selection_action.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../providers/photos_controller.dart';
import '../providers/pick_controller.dart';
import '../providers/session_controller.dart';
import '../services/download_service.dart';
import '../theme/design_tokens.dart';
import '../widgets/details/details_sheet.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_settings_row.dart';
import '../widgets/noo/media/noo_photo_group.dart';
import '../widgets/noo/media/noo_photo_tile.dart';
import '../widgets/noo/noo_layout.dart';
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 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart';
/// The type chips PhotosController actually has data for (DESIGN_SYSTEM.md
/// section 4 also lists "Camera", but there's no camera-upload/EXIF source
/// to distinguish that from any other photo - see the view's doc comment).
enum _PhotoTypeFilter { all, image, video }
/// A contiguous run of [PhotosController.items] that shares a calendar
/// month, in whatever order the controller already sorted them - see
/// [_PhotosViewState._groupByMonth].
class _MonthGroup {
final DateTime month;
final List<NextcloudItem> items;
_MonthGroup(this.month, this.items);
}
/// The Photos tab: every image/video across the account, grouped by month
/// (DESIGN_SYSTEM.md section 4). Renders content only - the shell
/// (`MainShellView`) owns the top bar, bottom bar, drawer and FAB.
class PhotosView extends StatefulWidget {
final ScrollController scrollController;
const PhotosView({super.key, required this.scrollController});
@override
State<PhotosView> createState() => _PhotosViewState();
}
class _PhotosViewState extends State<PhotosView>
with SingleTickerProviderStateMixin {
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,
// same way the type chips narrow Files' listing without touching the
// controller.
_PhotoTypeFilter _typeFilter = _PhotoTypeFilter.all;
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);
}
/// 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(
BuildContext context,
PickController pick,
NextcloudItem item,
) {
if (!pick.itemMatchesPickFilter(item)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("This app can't accept this file type"),
behavior: SnackBarBehavior.floating,
),
);
return;
}
if (pick.pickRequest!.allowMultiple) {
_toggleSelection(item);
} else {
pick.confirmPick([item]);
}
}
/// The bulk actions shown in the sticky selection toolbar for the
/// currently-selected items.
List<SelectionAction> _buildSelectionActions(
PickController pick,
List<NextcloudItem> selected,
) {
if (pick.isPicking) {
return [
SelectionAction(
icon: Icons.check_rounded,
label: 'Use ${selected.length} item(s)',
onTap: () => pick.confirmPick(selected),
),
];
}
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',
onTap: () => _favoriteSelected(selected),
),
SelectionAction(
icon: Icons.share_rounded,
label: 'Share',
onTap: () => selected.length == 1
? ShareSheet.show(context, selected.single)
: _shareSelected(context, selected),
),
SelectionAction(
icon: Icons.download_rounded,
label: 'Download',
onTap: () => _downloadSelected(context, selected),
),
SelectionAction(
icon: Icons.delete_outline_rounded,
label: 'Delete',
onTap: () => _confirmDeleteSelected(context, selected),
),
SelectionAction(
icon: Icons.copy_rounded,
label: 'Copy',
onTap: () => _moveOrCopySelected(selected, copy: true),
),
SelectionAction(
icon: Icons.drive_file_move_rounded,
label: 'Move',
onTap: () => _moveOrCopySelected(selected, copy: false),
),
if (selected.length == 1)
SelectionAction(
icon: Icons.info_outline_rounded,
label: 'Details',
onTap: () => DetailsSheet.show(context, selected.single),
),
];
}
/// Splits an already-filtered/sorted list into contiguous month runs
/// (`dateCreated`) in list order - deliberately not a global re-sort by
/// date, so a "Name"/"Size" sort chip still governs the order shown
/// (see the group header row it feeds).
List<_MonthGroup> _groupByMonth(List<NextcloudItem> items) {
final groups = <_MonthGroup>[];
for (final item in items) {
final d = item.dateCreated;
final month = DateTime(d.year, d.month);
if (groups.isNotEmpty && groups.last.month == month) {
groups.last.items.add(item);
} else {
groups.add(_MonthGroup(month, [item]));
}
}
return groups;
}
bool _matchesTypeFilter(NextcloudItem item) {
switch (_typeFilter) {
case _PhotoTypeFilter.all:
return true;
case _PhotoTypeFilter.image:
return item.type == NextcloudItemType.image;
case _PhotoTypeFilter.video:
return item.type == NextcloudItemType.video;
}
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final isDesktop = NooLayout.isDesktop(context);
final photosController = context.watch<PhotosController>();
final filesController = context.watch<FilesController>();
final pick = context.watch<PickController>();
if (!_requested) {
_requested = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => photosController.fetchAllMedia(),
);
}
final allPhotos = photosController.items;
final visiblePhotos = allPhotos.where(_matchesTypeFilter).toList();
// Selection tracks the full (favorites/hidden-filtered) list, not just
// the type-filtered view, so switching the type chip mid-selection
// never silently drops a selected item from the bulk actions.
final selectedItems = allPhotos
.where((i) => _selectedIds.contains(i.id))
.toList();
final groups = _groupByMonth(visiblePhotos);
final controlsRow = _buildControlsRow(
context,
photosController,
filesController,
);
final List<Widget> contentSlivers = [
// 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,
),
child: controlsRow,
),
),
),
if (photosController.isLoading && allPhotos.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (photosController.errorMessage != null)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
const SizedBox(height: 16),
Text(
'Could not load photos',
style: NooText.cardTitle.copyWith(color: colors.danger),
),
const SizedBox(height: 8),
Text(
photosController.errorMessage!,
textAlign: TextAlign.center,
style: NooText.body.copyWith(color: colors.fg3),
),
const SizedBox(height: 20),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.field,
icon: LucideIcons.refreshCw,
onTap: photosController.fetchAllMedia,
child: const Text('Retry'),
),
],
),
),
),
)
else if (visiblePhotos.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.image, size: 56, color: colors.fg3),
const SizedBox(height: 12),
Text(
allPhotos.isEmpty
? 'No photos found in Nextcloud library'
: 'No photos match this filter',
style: NooText.cardTitle.copyWith(color: colors.fg2),
),
],
),
),
)
else
for (final group in groups) ...[
SliverToBoxAdapter(
child: NooPhotoGroupHeader(
title: DateFormat('MMMM yyyy').format(group.month),
count:
'${group.items.length} ${group.items.length == 1 ? 'item' : 'items'}',
padding: EdgeInsets.fromLTRB(
NooLayout.gutter(context),
NooSpace.lg,
NooLayout.gutter(context),
NooSpace.sm,
),
),
),
NooPhotoGrid(
itemCount: group.items.length,
columns: isDesktop
? NooPhotoGrid.desktopColumns
: NooPhotoGrid.mobileColumns,
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
),
itemBuilder: (context, index) =>
_buildPhotoTile(context, group.items[index], visiblePhotos),
),
],
// Fixed clearance so the last row isn't hidden behind the floating
// nav bar, regardless of grid length.
const SliverToBoxAdapter(child: SizedBox(height: 100)),
];
return PopScope(
canPop: !_isSelecting,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelecting) _clearSelection();
},
child: ColoredBox(
color: colors.bg,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: photosController.fetchAllMedia,
child: CustomScrollView(
controller: widget.scrollController,
slivers: contentSlivers,
),
),
),
);
}
/// Type chips (All/Photos/Videos - "Camera" from the spec has no data
/// source, see the view's doc comment), a sort chip and a filter chip for
/// favorites-only/hidden/external-storage, mirroring `FilesControlsRow`'s
/// layout/pattern.
Widget _buildControlsRow(
BuildContext context,
PhotosController photos,
FilesController files,
) {
final filtersActive =
photos.showFavoritesOnly ||
photos.showHidden ||
files.storageScope != StorageScope.cloud;
return SizedBox(
height: 44,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
NooChip(
icon: photos.sortAscending
? LucideIcons.arrowUp
: LucideIcons.arrowDown,
onTap: () => _showSortSheet(context, photos),
child: Text(sortFieldLabel(photos.sortField)),
),
const SizedBox(width: 8),
NooChip(
selected: _typeFilter == _PhotoTypeFilter.all,
onTap: () => setState(() => _typeFilter = _PhotoTypeFilter.all),
child: const Text('All'),
),
const SizedBox(width: 8),
NooChip(
selected: _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),
child: const Text('Videos'),
),
const SizedBox(width: 8),
NooChip(
icon: LucideIcons.filter,
trailing: NooChipTrailing.menu,
selected: filtersActive,
onTap: () => _showFilterSheet(context, photos, files),
child: const Text('Filter'),
),
],
),
),
);
}
void _showSortSheet(BuildContext context, PhotosController photos) {
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);
},
),
],
),
],
);
}
void _showFilterSheet(
BuildContext context,
PhotosController photos,
FilesController files,
) {
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,
),
),
),
],
),
],
);
}
/// 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.
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,
),
],
),
),
),
],
),
);
}
Widget _buildPhotoTile(
BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
final pick = context.watch<PickController>();
final session = context.watch<SessionController>();
final isSelected = _selectedIds.contains(photo.id);
final isVideo = photo.type == NextcloudItemType.video;
return NooPhotoTile(
selected: isSelected,
selectionMode: _isSelecting,
// No real duration source (no video-metadata extraction anywhere in
// the app) - an empty string still marks it as a video and shows the
// icon-only badge (DESIGN_SYSTEM.md: "else icon-only").
videoDuration: isVideo ? '' : null,
placeholderColor: NooPhotoTile.paletteColor(photo.id.hashCode),
onTap: () {
if (pick.isPicking) {
_handlePickTap(context, pick, photo);
} else if (_isSelecting) {
_toggleSelection(photo);
} else {
_openLightbox(context, photo, siblings);
}
},
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
? null
: () => _toggleSelection(photo),
child: photo.previewUrl != null
? LayoutBuilder(
builder: (context, constraints) {
// Decode at the tile's actual rendered size rather than the
// full 500x500 preview the server returns - cheaper to
// decode/cache while scrolling a grid.
final cachePixels =
(constraints.maxWidth *
MediaQuery.of(context).devicePixelRatio)
.round();
return Image.network(
photo.previewUrl!,
headers: session.service?.authHeaders,
fit: BoxFit.cover,
cacheWidth: cachePixels,
cacheHeight: cachePixels,
filterQuality: FilterQuality.low,
gaplessPlayback: true,
errorBuilder: (ctx, err, stack) =>
_buildFallbackTile(context, photo),
);
},
)
: _buildFallbackTile(context, photo),
);
}
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
final colors = context.nooColors;
final kind = NooFileKind.from(name: photo.name, mimeType: photo.mimeType);
return ColoredBox(
color: kind.background(colors),
child: Center(
child: Icon(kind.icon, color: kind.foreground(colors), size: 32),
),
);
}
void _openLightbox(
BuildContext context,
NextcloudItem photo,
List<NextcloudItem> siblings,
) {
Navigator.push(
context,
FileViewerScreen.route(item: photo, siblings: siblings),
);
}
Future<void> _favoriteSelected(List<NextcloudItem> items) async {
final ops = context.read<ItemOperations>();
final allFavorited = items.every((i) => i.isFavorite);
for (final item in items) {
if (item.isFavorite == allFavorited) {
await ops.toggleItemFavorite(item);
}
}
_clearSelection();
}
Future<void> _shareSelected(
BuildContext context,
List<NextcloudItem> items,
) async {
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(
content: Text('Creating share link(s)…'),
behavior: SnackBarBehavior.floating,
),
);
final lines = <String>[];
for (final item in items) {
final link = await ops.createShareLink(item);
if (link != null) {
lines.add(items.length > 1 ? '${item.name}: $link' : link);
}
}
if (!mounted) return;
messenger.hideCurrentSnackBar();
_clearSelection();
if (lines.isEmpty) {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not create share link(s)'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
try {
await SharePlus.instance.share(ShareParams(text: lines.join('\n')));
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Could not open the share sheet'),
behavior: SnackBarBehavior.floating,
),
);
}
}
/// Hands the whole batch off to `DownloadService.kt` - see
/// `FilesView._downloadSelected`'s identical doc comment for why.
Future<void> _downloadSelected(
BuildContext context,
List<NextcloudItem> items,
) async {
final session = context.read<SessionController>();
_clearSelection();
final messenger = ScaffoldMessenger.of(context);
try {
await DownloadService.startDownload(session, items);
messenger.showSnackBar(
SnackBar(
content: Text(
items.length == 1
? 'Downloading ${items.first.name} - see the notification for progress'
: 'Downloading ${items.length} files - see the notification for progress',
),
behavior: SnackBarBehavior.floating,
),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Could not start download: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _confirmDeleteSelected(
BuildContext context,
List<NextcloudItem> items,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Delete Items'),
content: Text(
'Delete ${items.length} item(s) from the server? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true || !context.mounted) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
var succeeded = 0;
for (final item in items) {
final success = await ops.deleteItem(item.path);
if (success) succeeded++;
}
_clearSelection();
messenger.showSnackBar(
SnackBar(
content: Text('Deleted $succeeded of ${items.length} item(s)'),
behavior: SnackBarBehavior.floating,
),
);
}
Future<void> _moveOrCopySelected(
List<NextcloudItem> items, {
required bool copy,
}) async {
final result = await MoveCopyDestinationPicker.show(
context,
items,
copy: copy,
);
if (!mounted || result == null) return;
_clearSelection();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${copy ? 'Copied' : 'Moved'} ${result.succeeded} of ${items.length} item(s)',
),
behavior: SnackBarBehavior.floating,
),
);
}
}