- 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>
435 lines
14 KiB
Dart
435 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import '../models/nextcloud_item.dart';
|
|
import '../providers/trash_controller.dart';
|
|
import '../theme/design_tokens.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/files/noo_file_tile.dart';
|
|
import '../widgets/noo/lists/noo_banner.dart';
|
|
import '../widgets/noo/noo_layout.dart';
|
|
import '../widgets/tabs/tab_location.dart';
|
|
import '../widgets/tabs/tab_state_slivers.dart';
|
|
|
|
/// The Trash tab: a retention banner (`NooBanner`, with "Empty trash" as
|
|
/// its danger action) then the trashed-items list, per `DESIGN_SYSTEM.md`
|
|
/// §4. There's no dedicated "empty trash" endpoint anywhere in
|
|
/// [TrashController]/`NextcloudService` - [_emptyTrash] composes it from
|
|
/// the existing per-item [TrashController.deleteForever], the same way
|
|
/// `FavoritesView._confirmDeleteSelected` builds its bulk delete from a
|
|
/// per-item call.
|
|
class TrashView extends StatefulWidget {
|
|
final ScrollController scrollController;
|
|
|
|
const TrashView({super.key, required this.scrollController});
|
|
|
|
@override
|
|
State<TrashView> createState() => _TrashViewState();
|
|
}
|
|
|
|
class _TrashViewState extends State<TrashView> {
|
|
bool _requested = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
final trashController = context.watch<TrashController>();
|
|
|
|
if (!_requested) {
|
|
_requested = true;
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => trashController.fetchAll(),
|
|
);
|
|
}
|
|
|
|
final trash = trashController.items;
|
|
final isDesktop = NooLayout.isDesktop(context);
|
|
|
|
final List<Widget> contentSlivers = [
|
|
SliverPadding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
NooLayout.gutter(context),
|
|
NooSpace.md,
|
|
NooLayout.gutter(context),
|
|
0,
|
|
),
|
|
sliver: SliverToBoxAdapter(
|
|
child: NooBanner(
|
|
actionLabel: trash.isEmpty ? null : 'Empty trash',
|
|
onAction: trash.isEmpty
|
|
? null
|
|
: () => _confirmEmptyTrash(context, trash),
|
|
child: const Text(
|
|
'Deleted items are kept for 30 days, then removed automatically.',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
|
if (trashController.isLoading && trash.isEmpty)
|
|
tabLoadingSliver
|
|
else if (trashController.errorMessage != null)
|
|
tabErrorSliver(
|
|
context,
|
|
title: 'Could not load trash',
|
|
message: trashController.errorMessage!,
|
|
onRetry: trashController.fetchAll,
|
|
)
|
|
else if (trash.isEmpty)
|
|
tabEmptySliver(
|
|
context,
|
|
icon: LucideIcons.trash2,
|
|
message: 'Trash is empty',
|
|
)
|
|
else if (isDesktop)
|
|
_buildDesktopTable(context, trash)
|
|
else
|
|
_buildMobileList(context, trash),
|
|
...tabBottomInsetSlivers,
|
|
];
|
|
|
|
return ColoredBox(
|
|
color: colors.bg,
|
|
child: RefreshIndicator(
|
|
color: colors.accent,
|
|
backgroundColor: colors.surface,
|
|
onRefresh: trashController.fetchAll,
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// A lazily-built `SliverList`, not `NooGroupedList` (its own `Column`
|
|
// isn't lazy - see files_view.dart's `_buildMobileRow` doc comment for
|
|
// the same reasoning): Trash can hold hundreds of items, and building
|
|
// every row eagerly up front is what made this tab laggy. Each row still
|
|
// reads as one continuous radius-20 card via per-row corner rounding +
|
|
// a 1px `line` divider, matching `NooGroupedList`'s look.
|
|
Widget _buildMobileList(BuildContext context, List<NextcloudItem> trash) {
|
|
final colors = context.nooColors;
|
|
return SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final item = trash[index];
|
|
// The overflow menu's only action here is "Delete forever" - a
|
|
// bare ellipsis obscures that behind an extra tap to discover it,
|
|
// so this row shows the delete icon directly instead of going
|
|
// through `NooFileRow.onMore`.
|
|
final row = NooFileRow(
|
|
kind: NooFileKind.from(
|
|
name: item.name,
|
|
mimeType: item.mimeType,
|
|
isDirectory: item.isFolder,
|
|
),
|
|
name: item.name,
|
|
meta: _meta(item),
|
|
iosStyle: NooLayout.iosStyle(context),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Semantics(
|
|
button: true,
|
|
label: 'Restore',
|
|
child: InkResponse(
|
|
onTap: () => _restore(context, item),
|
|
radius: 20,
|
|
child: const Icon(LucideIcons.rotateCcw),
|
|
),
|
|
),
|
|
const SizedBox(width: NooSpace.xs),
|
|
Semantics(
|
|
button: true,
|
|
label: 'Delete forever',
|
|
child: InkResponse(
|
|
onTap: () => _confirmDeleteForever(context, item),
|
|
radius: 20,
|
|
child: Icon(LucideIcons.trash2, color: colors.danger),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final isFirst = index == 0;
|
|
final isLast = index == trash.length - 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),
|
|
],
|
|
);
|
|
}, childCount: trash.length),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDesktopTable(BuildContext context, List<NextcloudItem> trash) {
|
|
return SliverPadding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: NooSpace.xl,
|
|
vertical: NooSpace.xs,
|
|
),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
if (index == 0) {
|
|
return const NooFileTableHeader(
|
|
col2Label: 'Deleted',
|
|
col3Label: 'Original location',
|
|
);
|
|
}
|
|
final item = trash[index - 1];
|
|
return _TrashDesktopRow(
|
|
kind: NooFileKind.from(
|
|
name: item.name,
|
|
mimeType: item.mimeType,
|
|
isDirectory: item.isFolder,
|
|
),
|
|
name: item.name,
|
|
col2: _deletedLabel(item),
|
|
col3: item.originalLocation ?? 'Unknown location',
|
|
onRestore: () => _restore(context, item),
|
|
onDelete: () => _confirmDeleteForever(context, item),
|
|
);
|
|
}, childCount: trash.length + 1),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _meta(NextcloudItem item) =>
|
|
'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}';
|
|
|
|
String _deletedLabel(NextcloudItem item) => item.deletedAt != null
|
|
? DateFormat.yMMMd().format(item.deletedAt!)
|
|
: 'recently';
|
|
|
|
Future<void> _restore(BuildContext context, NextcloudItem item) async {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final success = await context.read<TrashController>().restore(item);
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
success ? 'Restored ${item.name}' : 'Failed to restore ${item.name}',
|
|
),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmDeleteForever(
|
|
BuildContext context,
|
|
NextcloudItem item,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: const Text('Delete forever'),
|
|
content: Text(
|
|
'Permanently delete "${item.name}"? 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 forever'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (confirmed != true || !context.mounted) return;
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final success = await context.read<TrashController>().deleteForever(item);
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
success ? 'Deleted ${item.name}' : 'Failed to delete ${item.name}',
|
|
),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmEmptyTrash(
|
|
BuildContext context,
|
|
List<NextcloudItem> trash,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: const Text('Empty trash'),
|
|
content: Text(
|
|
'Permanently delete all ${trash.length} item(s) in trash? 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('Empty trash'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (confirmed != true || !context.mounted) return;
|
|
|
|
final controller = context.read<TrashController>();
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
var succeeded = 0;
|
|
for (final item in trash) {
|
|
if (await controller.deleteForever(item)) succeeded++;
|
|
}
|
|
if (!context.mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text('Deleted $succeeded of ${trash.length} item(s)'),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
|
|
/// holds a tonal "Restore" button plus a danger-tonal "Delete" button
|
|
/// (permanent delete) instead of status icons - `NooFileTableRow` has no
|
|
/// slot for screen-specific actions there. A labelled pill, not a bare
|
|
/// overflow icon: it's the row's only destructive action, not a menu of
|
|
/// several, and desktop already prefers labelled buttons to bare icons
|
|
/// (see `NooSelectionBar`'s own desktop actions). Column widths
|
|
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
|
|
/// `noo_file_table.dart`) so this still lines up under it; promoting an
|
|
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
|
|
/// into the shared component.
|
|
class _TrashDesktopRow extends StatelessWidget {
|
|
final NooFileKind kind;
|
|
final String name;
|
|
final String col2;
|
|
final String col3;
|
|
final VoidCallback onRestore;
|
|
final VoidCallback onDelete;
|
|
|
|
const _TrashDesktopRow({
|
|
required this.kind,
|
|
required this.name,
|
|
required this.col2,
|
|
required this.col3,
|
|
required this.onRestore,
|
|
required this.onDelete,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
|
|
|
|
return SizedBox(
|
|
height: NooSizes.rowDesktop,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
|
|
const SizedBox(width: NooSpace.sm),
|
|
Expanded(
|
|
child: Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: NooText.body.copyWith(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: colors.fg1,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: NooSpace.md),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 180,
|
|
child: Text(
|
|
col2,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: metaStyle,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 160,
|
|
child: Text(
|
|
col3,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: metaStyle,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 120,
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerRight,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
NooButton(
|
|
variant: NooButtonVariant.tonal,
|
|
size: NooButtonSize.xs,
|
|
onTap: onRestore,
|
|
child: const Text('Restore'),
|
|
),
|
|
const SizedBox(width: 4),
|
|
NooButton(
|
|
variant: NooButtonVariant.danger,
|
|
size: NooButtonSize.xs,
|
|
onTap: onDelete,
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|