Files
noo/lib/views/move_copy_destination_picker.dart
T
ayushyaandClaude Sonnet 5 b73f451fff Fix pull-to-refresh, header tint, sheet controls, and Shares tap
- Every tab's RefreshIndicator+CustomScrollView was missing an explicit
  AlwaysScrollableScrollPhysics, so pull-to-refresh silently couldn't be
  triggered once the list was short enough to fit the viewport (empty,
  or a single item) - the platform default physics only allows the
  overscroll drag RefreshIndicator listens for once content overflows.
- StickyHeaderDelegate (Files'/Photos'/Favorites' pinned controls row)
  painted its background from Theme.of(context).colorScheme.surface -
  Flutter's own Material 3 scheme, reseeded by the user's accent color
  choice - instead of context.nooColors.bg like the rest of the screen,
  showing as a stray colored cast behind the row.
- NooSegmentedControl gains onSurface: true wherever one sits inside a
  sheet (Files'/Photos' sort and type-filter controls) - it defaulted to
  the same surface fill as the sheet itself, so the control had no
  visible boundary showing it as a grouped unit.
- Shares tab: removed the per-row "..." overflow menu on every scope
  (With you/By you/Links); tapping a row now opens the full Share sheet
  for that item instead, fetched via the new
  FilesController.fetchItemAtPath (a share only carries enough metadata
  for its own row, not what the sheet's header needs).
- fetchShares() now reuses _shareFromJson instead of its own thinner
  inline parser, so url/token/permissions/expireDate are actually
  populated - a public-link share could never show "Copy link" before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:45:05 -04:00

432 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:provider/provider.dart';
import '../models/move_copy_result.dart';
import '../models/nextcloud_item.dart';
import '../providers/files_controller.dart';
import '../providers/item_operations.dart';
import '../theme/design_tokens.dart';
import '../widgets/breadcrumbs.dart';
import '../widgets/files_controls_row.dart';
import '../widgets/more_tabs_button.dart';
import '../widgets/move_copy_conflict_sheet.dart';
import '../widgets/noo/core/noo_button.dart';
import '../widgets/noo/files/noo_file_kind.dart';
import '../widgets/noo/files/noo_file_row.dart';
import '../widgets/noo/files/noo_file_table.dart';
import '../widgets/noo/media/noo_grid_card.dart';
import '../widgets/noo/nav/noo_top_bar.dart';
import '../widgets/noo/nav/noo_toolbar.dart';
import '../widgets/noo/noo_layout.dart';
import '../widgets/tabs/tab_state_slivers.dart';
/// Destination-folder browser for moving/copying [items] (a multi-select
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
/// browser (same chrome/controls/listing), but deliberately does **not**
/// reuse `FilesController`'s shared `currentFolderPath`/`pathStack`/`items`
/// navigation state the way that screen does - this is pushed mid-browsing
/// session (the user was already looking at a specific Files-tab folder
/// when they selected items and tapped Move/Copy), so clobbering that
/// shared state here would strand the Files tab in whatever folder this
/// picker last visited. Instead it owns its own local navigation state and
/// fetches through `FilesController.fetchFolderListing`, a stateless
/// pass-through that never touches shared fields - `ShareUploadView` gets
/// away with the shared state precisely because it always resets to root
/// and pops all the way to the app root afterward (a cold share-intent
/// launch has no prior browsing session to preserve); that doesn't hold
/// here.
class MoveCopyDestinationPicker extends StatefulWidget {
final List<NextcloudItem> items;
final bool copy;
const MoveCopyDestinationPicker({
super.key,
required this.items,
required this.copy,
});
/// Pushes the picker and returns the final [MoveCopyResult] (already
/// including any conflict resolution), or null if the user backed out
/// without confirming.
static Future<MoveCopyResult?> show(
BuildContext context,
List<NextcloudItem> items, {
required bool copy,
}) {
return Navigator.of(context).push<MoveCopyResult>(
MaterialPageRoute(
builder: (_) => MoveCopyDestinationPicker(items: items, copy: copy),
),
);
}
@override
State<MoveCopyDestinationPicker> createState() =>
_MoveCopyDestinationPickerState();
}
class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
final ScrollController _scrollController = ScrollController();
List<String> _pathStack = const ['/'];
List<NextcloudItem> _rawItems = [];
bool _isLoading = true;
bool _isSubmitting = false;
String get _currentPath => _pathStack.last;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch(_currentPath));
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _fetch(String path) async {
setState(() => _isLoading = true);
final raw = await context.read<FilesController>().fetchFolderListing(path);
if (!mounted) return;
setState(() {
_rawItems = raw;
_isLoading = false;
});
}
void _navigateToFolder(String path) {
setState(() => _pathStack = [..._pathStack, path]);
_fetch(path);
}
void _navigateToPathIndex(int index) {
if (index < 0 || index >= _pathStack.length - 1) return;
setState(() => _pathStack = _pathStack.sublist(0, index + 1));
_fetch(_currentPath);
}
bool _navigateUp() {
if (_pathStack.length <= 1) return false;
setState(() => _pathStack = _pathStack.sublist(0, _pathStack.length - 1));
_fetch(_currentPath);
return true;
}
/// Client-side mirror of `ItemOperations`' own authoritative check (see
/// `_isSelfOrDescendant`) - just for disabling the confirm button with an
/// explanation up front instead of letting the request round-trip and
/// fail.
bool _destinationIsInvalid() {
final dest = _currentPath.endsWith('/') ? _currentPath : '$_currentPath/';
for (final item in widget.items) {
if (!item.isFolder) continue;
final folder = item.path.endsWith('/') ? item.path : '${item.path}/';
if (dest == folder || dest.startsWith(folder)) return true;
}
return false;
}
Future<void> _confirm(ItemOperations ops) async {
setState(() => _isSubmitting = true);
final result = widget.copy
? await ops.copyItems(widget.items, _currentPath)
: await ops.moveItems(widget.items, _currentPath);
if (!mounted) return;
if (result.blockedReason != null) {
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.blockedReason!),
behavior: SnackBarBehavior.floating,
),
);
return;
}
var succeeded = result.succeeded;
var failed = result.failed;
if (result.conflicts.isNotEmpty) {
final choices = await MoveCopyConflictSheet.show(
context,
result.conflicts,
);
if (!mounted) return;
if (choices != null) {
final resolved = await ops.resolveConflicts(
result.conflicts,
_currentPath,
copy: widget.copy,
choices: choices,
);
succeeded += resolved.succeeded;
failed += resolved.failed;
}
// A dismissed sheet (choices == null) leaves those conflicts
// untouched at the source - same outcome as explicitly skipping
// every one, just without a network round-trip to get there.
}
if (!mounted) return;
Navigator.of(
context,
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
}
// Only folders are valid Move/Copy destinations, so the listing - unlike
// Files' own - never shows plain files at all.
Widget _buildRow(NextcloudItem item, int index, int count) {
final colors = context.nooColors;
final row = NooFileRow(
kind: NooFileKind.folder,
name: item.name,
meta: 'Folder',
iosStyle: NooLayout.iosStyle(context),
onTap: () => _navigateToFolder(item.path),
);
final isFirst = index == 0;
final isLast = index == count - 1;
return Column(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
),
child: row,
),
if (!isLast) Container(height: 1, color: colors.line),
],
);
}
Widget _buildDesktopRow(NextcloudItem item) {
return NooFileTableRow(
kind: NooFileKind.folder,
name: item.name,
onTap: () => _navigateToFolder(item.path),
);
}
Widget _buildGridCard(NextcloudItem item) {
final colors = context.nooColors;
return NooGridCard(
name: item.name,
meta: 'Folder',
placeholderColor: colors.accentSoft,
icon: LucideIcons.folder,
iconColor: colors.accentText,
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
onTap: () => _navigateToFolder(item.path),
);
}
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
final files = context.watch<FilesController>();
final ops = context.read<ItemOperations>();
final hasBreadcrumbs = _pathStack.length > 1;
final folders = files
.applyFilesDisplayPrefs(_rawItems)
.where((item) => item.isFolder)
.toList();
final invalidDestination = _destinationIsInvalid();
final gutter = NooLayout.gutter(context);
final verb = widget.copy ? 'Copying' : 'Moving';
final itemCount = widget.items.length;
final slivers = <Widget>[
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(
gutter,
NooSpace.md,
gutter,
NooSpace.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 44,
child: FilesControlsRow(folderPath: _currentPath),
),
if (hasBreadcrumbs) ...[
const SizedBox(height: 10),
SizedBox(
height: 32,
child: Breadcrumbs(
pathStack: _pathStack,
onTap: _navigateToPathIndex,
),
),
],
],
),
),
),
if (_isLoading)
tabLoadingSliver
else if (folders.isEmpty)
tabEmptySliver(
context,
icon: LucideIcons.folder,
message: 'No folders here',
)
else if (files.isGridView)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2,
childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _buildGridCard(folders[index]),
childCount: folders.length,
),
),
)
else if (desktop)
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildDesktopRow(folders[index]),
childCount: folders.length,
),
),
)
else
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_buildRow(folders[index], index, folders.length),
childCount: folders.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
];
final title = widget.copy ? 'Copy to...' : 'Move to...';
return PopScope(
canPop: _pathStack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _navigateUp();
},
child: Scaffold(
backgroundColor: colors.bg,
appBar: desktop
? NooToolbar(
title: title,
actions: [
const MoreTabsButton(),
const SizedBox(width: NooSpace.xs),
NooButton(
variant: NooButtonVariant.secondary,
size: NooButtonSize.toolbar,
onTap: () => Navigator.maybePop(context),
child: const Text('Cancel'),
),
],
)
: NooTopBar(
style: NooLayout.navStyle(context),
title: title,
leading: const NooTopBarBack(label: 'Cancel'),
// Stacks a hidden tab's own screen on top rather than
// jumping the shell there, so it doesn't abandon this
// picker - safe to keep, unlike ShareUploadView's chrome.
actions: const [MoreTabsButton()],
),
body: SafeArea(
top: false,
child: RefreshIndicator(
color: colors.accent,
backgroundColor: colors.surface,
onRefresh: () => _fetch(_currentPath),
child: CustomScrollView(
controller: _scrollController,
// See files_view.dart's identical fix - without this, pull-
// to-refresh can't be triggered on an empty or single-folder
// listing.
physics: const AlwaysScrollableScrollPhysics(),
slivers: slivers,
),
),
),
bottomNavigationBar: DecoratedBox(
decoration: BoxDecoration(
color: colors.surface,
border: Border(top: BorderSide(color: colors.line)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
NooSpace.lg,
NooSpace.md,
NooSpace.lg,
NooSpace.md,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
invalidDestination
? "Can't ${widget.copy ? 'copy' : 'move'} a folder into "
"itself or one of its own subfolders"
: '$verb $itemCount item${itemCount == 1 ? '' : 's'}',
textAlign: TextAlign.center,
style: NooText.meta.copyWith(
color: invalidDestination ? colors.danger : colors.fg3,
),
),
const SizedBox(height: NooSpace.sm),
NooButton(
variant: NooButtonVariant.primary,
size: NooButtonSize.cta,
fullWidth: true,
disabled: invalidDestination || _isSubmitting,
icon: _isSubmitting
? null
: (widget.copy
? LucideIcons.copy
: LucideIcons.folderInput),
onTap: invalidDestination || _isSubmitting
? null
: () => _confirm(ops),
child: _isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(widget.copy ? 'Copy here' : 'Move here'),
),
],
),
),
),
),
),
);
}
}