- Every tab now wraps its CustomScrollView in SafeArea(top: true) - the pinned sort/filter row was riding up underneath the status bar once the floating top bar above it fully collapsed, since that bar's own internal SafeArea only reserved the inset while it had some height left to put it in. Reserving it outside the scrolling region entirely fixes this regardless of the floating header's state. Caught live on device and backed by a new regression test. - The avatar menu now covers the top bar (title included) instead of sitting below it - positioned off just the status-bar inset rather than the top bar's full height on top of that. - The avatar menu's card now carries a second, tighter contact shadow alongside nooDialogShadow - that shadow alone was essentially invisible on a small card over a dark theme's near-black background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
447 lines
15 KiB
Dart
447 lines
15 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;
|
|
|
|
/// 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;
|
|
|
|
const TrashView({super.key, required this.scrollController, this.topBar});
|
|
|
|
@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 = [
|
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
|
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(context),
|
|
];
|
|
|
|
return ColoredBox(
|
|
color: colors.bg,
|
|
child: RefreshIndicator(
|
|
color: colors.accent,
|
|
backgroundColor: colors.surface,
|
|
onRefresh: trashController.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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|