Selection and batch operations in file list

This commit is contained in:
2025-11-18 21:17:09 +08:00
parent 4409a6fb1e
commit db5199438a
7 changed files with 372 additions and 149 deletions

View File

@@ -1336,5 +1336,7 @@
"fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.", "fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.",
"amountOfSplits": "Amount of Splits", "amountOfSplits": "Amount of Splits",
"enterNumberOfSplits": "Enter Splits Amount", "enterNumberOfSplits": "Enter Splits Amount",
"orCreateWith": "Or\ncreate with" "orCreateWith": "Or\ncreate with",
"unindexedFiles": "Unindexed files",
"folder": "Folder"
} }

View File

@@ -451,7 +451,7 @@ class PollEditorScreen extends ConsumerWidget {
), ),
); );
if (confirmed == true) { if (confirmed == true) {
Navigator.of(context).pop(); if (context.mounted) Navigator.of(context).pop();
} }
}, },
child: Column( child: Column(

View File

@@ -29,7 +29,7 @@ Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Enter access token', hintText: 'Enter access token',
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
), ),
), ),
autofocus: true, autofocus: true,

View File

@@ -67,6 +67,51 @@ class FileListView extends HookConsumerWidget {
if (usage == null) return const SizedBox.shrink(); if (usage == null) return const SizedBox.shrink();
final unindexedNotifier = ref.read(
unindexedFileListNotifierProvider.notifier,
);
final cloudNotifier = ref.read(cloudFileListNotifierProvider.notifier);
final recycled = useState<bool>(false);
final poolsAsync = ref.watch(poolsProvider);
final isSelectionMode = useState<bool>(false);
final selectedFileIds = useState<Set<String>>({});
final query = useState<String?>(null);
final order = useState<String?>('date');
final orderDesc = useState<bool>(true);
final queryDebounceTimer = useRef<Timer?>(null);
useEffect(() {
if (mode.value == FileListMode.unindexed) {
isSelectionMode.value = false;
selectedFileIds.value.clear();
}
return null;
}, [mode.value]);
useEffect(() {
// Sync pool when mode or selectedPool changes
if (mode.value == FileListMode.unindexed) {
unindexedNotifier.setPool(selectedPool.value?.id);
} else {
cloudNotifier.setPool(selectedPool.value?.id);
}
return null;
}, [selectedPool.value, mode.value]);
useEffect(() {
// Sync query, order, and orderDesc filters
if (mode.value == FileListMode.unindexed) {
unindexedNotifier.setQuery(query.value);
unindexedNotifier.setOrder(order.value);
unindexedNotifier.setOrderDesc(orderDesc.value);
} else {
cloudNotifier.setQuery(query.value);
cloudNotifier.setOrder(order.value);
cloudNotifier.setOrderDesc(orderDesc.value);
}
return null;
}, [query.value, order.value, orderDesc.value, mode.value]);
final isRefreshing = ref.watch( final isRefreshing = ref.watch(
mode.value == FileListMode.normal mode.value == FileListMode.normal
? cloudFileListNotifierProvider.select((value) => value.isLoading) ? cloudFileListNotifierProvider.select((value) => value.isLoading)
@@ -93,6 +138,8 @@ class FileListView extends HookConsumerWidget {
ref, ref,
context, context,
viewMode, viewMode,
isSelectionMode,
selectedFileIds,
), ),
), ),
_ => PagingHelperSliverView( _ => PagingHelperSliverView(
@@ -113,45 +160,12 @@ class FileListView extends HookConsumerWidget {
context, context,
currentPath, currentPath,
viewMode, viewMode,
isSelectionMode,
selectedFileIds,
), ),
), ),
}; };
final unindexedNotifier = ref.read(
unindexedFileListNotifierProvider.notifier,
);
final cloudNotifier = ref.read(cloudFileListNotifierProvider.notifier);
final recycled = useState<bool>(false);
final poolsAsync = ref.watch(poolsProvider);
final query = useState<String?>(null);
final order = useState<String?>('date');
final orderDesc = useState<bool>(true);
final queryDebounceTimer = useRef<Timer?>(null);
useEffect(() {
// Sync pool when mode or selectedPool changes
if (mode.value == FileListMode.unindexed) {
unindexedNotifier.setPool(selectedPool.value?.id);
} else {
cloudNotifier.setPool(selectedPool.value?.id);
}
return null;
}, [selectedPool.value, mode.value]);
useEffect(() {
// Sync query, order, and orderDesc filters
if (mode.value == FileListMode.unindexed) {
unindexedNotifier.setQuery(query.value);
unindexedNotifier.setOrder(order.value);
unindexedNotifier.setOrderDesc(orderDesc.value);
} else {
cloudNotifier.setQuery(query.value);
cloudNotifier.setOrder(order.value);
cloudNotifier.setOrderDesc(orderDesc.value);
}
return null;
}, [query.value, order.value, orderDesc.value, mode.value]);
late Widget pathContent; late Widget pathContent;
if (mode.value == FileListMode.unindexed) { if (mode.value == FileListMode.unindexed) {
pathContent = const Text( pathContent = const Text(
@@ -344,6 +358,23 @@ class FileListView extends HookConsumerWidget {
vertical: -4, vertical: -4,
), ),
), ),
IconButton(
icon: Icon(
isSelectionMode.value
? Symbols.close
: Symbols.select_check_box,
),
onPressed:
() => isSelectionMode.value = !isSelectionMode.value,
tooltip:
isSelectionMode.value
? 'Exit Selection Mode'
: 'Enter Selection Mode',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
if (mode.value == FileListMode.normal) if (mode.value == FileListMode.normal)
IconButton( IconButton(
icon: const Icon(Symbols.create_new_folder), icon: const Icon(Symbols.create_new_folder),
@@ -406,6 +437,75 @@ class FileListView extends HookConsumerWidget {
viewMode.value == FileListViewMode.waterfall ? 12 : null, viewMode.value == FileListViewMode.waterfall ? 12 : null,
), ),
), ),
if (isSelectionMode.value)
Material(
color: Theme.of(context).colorScheme.surfaceContainer,
elevation: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
TextButton(
onPressed: () {
isSelectionMode.value = false;
selectedFileIds.value.clear();
},
child: const Text('Cancel'),
),
const Spacer(),
Text('${selectedFileIds.value.length} selected'),
const Spacer(),
ElevatedButton.icon(
icon: const Icon(Symbols.delete),
label: const Text('Delete'),
onPressed:
selectedFileIds.value.isNotEmpty
? () async {
final confirmed = await showConfirmAlert(
'Are you sure you want to delete the selected files?',
'Delete Selected Files',
);
if (!confirmed) return;
if (context.mounted) {
showLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
final resp = await client.post(
'/drive/files/batches/delete',
data: {
'file_ids':
selectedFileIds.value.toList(),
},
);
final count = resp.data['count'] as int;
selectedFileIds.value.clear();
isSelectionMode.value = false;
ref.invalidate(
mode.value == FileListMode.normal
? cloudFileListNotifierProvider
: unindexedFileListNotifierProvider,
);
showSnackBar('Deleted $count files.');
} catch (e) {
showSnackBar(
'Failed to delete selected files.',
);
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
}
}
: null,
),
],
),
),
),
], ],
), ),
), ),
@@ -420,6 +520,8 @@ class FileListView extends HookConsumerWidget {
BuildContext context, BuildContext context,
ValueNotifier<String> currentPath, ValueNotifier<String> currentPath,
ValueNotifier<FileListViewMode> currentViewMode, ValueNotifier<FileListViewMode> currentViewMode,
ValueNotifier<bool> isSelectionMode,
ValueNotifier<Set<String>> selectedFileIds,
) { ) {
return switch (currentViewMode.value) { return switch (currentViewMode.value) {
// Waterfall mode // Waterfall mode
@@ -440,7 +542,23 @@ class FileListView extends HookConsumerWidget {
final item = items[index]; final item = items[index];
return item.map( return item.map(
file: (fileItem) => _buildWaterfallFileTile(fileItem, ref, context), file:
(fileItem) => _buildWaterfallFileTile(
fileItem,
ref,
context,
isSelectionMode.value,
selectedFileIds.value.contains(fileItem.fileIndex.id),
() {
if (selectedFileIds.value.contains(fileItem.fileIndex.id)) {
selectedFileIds.value = Set.from(selectedFileIds.value)
..remove(fileItem.fileIndex.id);
} else {
selectedFileIds.value = Set.from(selectedFileIds.value)
..add(fileItem.fileIndex.id);
}
},
),
folder: folder:
(folderItem) => (folderItem) =>
_buildWaterfallFolderTile(folderItem, currentPath, context), _buildWaterfallFolderTile(folderItem, currentPath, context),
@@ -461,58 +579,23 @@ class FileListView extends HookConsumerWidget {
final item = items[index]; final item = items[index];
return item.map( return item.map(
file: (fileItem) { file:
final file = fileItem.fileIndex.file; (fileItem) => _buildIndexedListTile(
return ListTile( fileItem,
leading: ClipRRect( ref,
borderRadius: const BorderRadius.all(Radius.circular(8)), context,
child: SizedBox( isSelectionMode.value,
height: 48, selectedFileIds.value.contains(fileItem.fileIndex.id),
width: 48, () {
child: getFileIcon(file, size: 24), if (selectedFileIds.value.contains(fileItem.fileIndex.id)) {
), selectedFileIds.value = Set.from(selectedFileIds.value)
), ..remove(fileItem.fileIndex.id);
title: } else {
file.name.isEmpty selectedFileIds.value = Set.from(selectedFileIds.value)
? Text('untitled').tr().italic() ..add(fileItem.fileIndex.id);
: Text(
file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(formatFileSize(file.size)),
onTap: () {
context.push('/files/${fileItem.fileIndex.id}', extra: file);
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) {
showLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/drive/index/remove/${fileItem.fileIndex.id}',
);
ref.invalidate(cloudFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
} }
}, },
), ),
);
},
folder: folder:
(folderItem) => ListTile( (folderItem) => ListTile(
leading: ClipRRect( leading: ClipRRect(
@@ -528,7 +611,7 @@ class FileListView extends HookConsumerWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: const Text('Folder'), subtitle: const Text('folder').tr(),
onTap: () { onTap: () {
final newPath = final newPath =
currentPath.value == '/' currentPath.value == '/'
@@ -639,6 +722,9 @@ class FileListView extends HookConsumerWidget {
FileItem fileItem, FileItem fileItem,
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
bool isSelectionMode,
bool isSelected,
VoidCallback? toggleSelection,
) { ) {
return _buildWaterfallFileTileBase( return _buildWaterfallFileTileBase(
fileItem.fileIndex.file, fileItem.fileIndex.file,
@@ -674,6 +760,9 @@ class FileListView extends HookConsumerWidget {
}, },
), ),
], ],
isSelectionMode,
isSelected,
toggleSelection,
); );
} }
@@ -683,6 +772,9 @@ class FileListView extends HookConsumerWidget {
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
List<Widget>? actions, List<Widget>? actions,
bool isSelectionMode,
bool isSelected,
VoidCallback? toggleSelection,
) { ) {
final meta = file.fileMeta is Map ? (file.fileMeta as Map) : const {}; final meta = file.fileMeta is Map ? (file.fileMeta as Map) : const {};
final ratio = final ratio =
@@ -750,7 +842,11 @@ class FileListView extends HookConsumerWidget {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () { onTap: () {
if (isSelectionMode && toggleSelection != null) {
toggleSelection();
} else {
context.push(getRoutePath(), extra: file); context.push(getRoutePath(), extra: file);
}
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -776,6 +872,12 @@ class FileListView extends HookConsumerWidget {
), ),
Row( Row(
children: [ children: [
if (isSelectionMode)
Checkbox(
value: isSelected,
onChanged: (value) => toggleSelection?.call(),
)
else
getFileIcon(file, size: 24, tinyPreview: false), getFileIcon(file, size: 24, tinyPreview: false),
const Gap(16), const Gap(16),
Expanded( Expanded(
@@ -861,6 +963,8 @@ class FileListView extends HookConsumerWidget {
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
ValueNotifier<FileListViewMode> currentViewMode, ValueNotifier<FileListViewMode> currentViewMode,
ValueNotifier<bool> isSelectionMode,
ValueNotifier<Set<String>> selectedFileIds,
) { ) {
return switch (currentViewMode.value) { return switch (currentViewMode.value) {
// Waterfall mode // Waterfall mode
@@ -894,6 +998,19 @@ class FileListView extends HookConsumerWidget {
unindexedFileItem, unindexedFileItem,
ref, ref,
context, context,
isSelectionMode.value,
selectedFileIds.value.contains(unindexedFileItem.file.id),
() {
if (selectedFileIds.value.contains(
unindexedFileItem.file.id,
)) {
selectedFileIds.value = Set.from(selectedFileIds.value)
..remove(unindexedFileItem.file.id);
} else {
selectedFileIds.value = Set.from(selectedFileIds.value)
..add(unindexedFileItem.file.id);
}
},
), ),
); );
}, childCount: widgetCount), }, childCount: widgetCount),
@@ -917,10 +1034,23 @@ class FileListView extends HookConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
unindexedFile: unindexedFile:
(unindexedFileItem) => _buildListUnindexedFileTile( (unindexedFileItem) => _buildUnindexedListTile(
unindexedFileItem, unindexedFileItem,
ref, ref,
context, context,
isSelectionMode.value,
selectedFileIds.value.contains(unindexedFileItem.file.id),
() {
if (selectedFileIds.value.contains(
unindexedFileItem.file.id,
)) {
selectedFileIds.value = Set.from(selectedFileIds.value)
..remove(unindexedFileItem.file.id);
} else {
selectedFileIds.value = Set.from(selectedFileIds.value)
..add(unindexedFileItem.file.id);
}
},
), ),
); );
}, },
@@ -928,10 +1058,149 @@ class FileListView extends HookConsumerWidget {
}; };
} }
Widget _buildIndexedListTile(
FileItem fileItem,
WidgetRef ref,
BuildContext context,
bool isSelectionMode,
bool isSelected,
VoidCallback toggleSelection,
) {
final file = fileItem.fileIndex.file;
return ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode)
Checkbox(
value: isSelected,
onChanged: (value) => toggleSelection(),
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: getFileIcon(file, size: 24),
),
),
],
),
title:
file.name.isEmpty
? Text('untitled').tr().italic()
: Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(formatFileSize(file.size)),
onTap: () {
if (isSelectionMode) {
toggleSelection();
} else {
context.push('/files/${fileItem.fileIndex.id}', extra: file);
}
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) {
showLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/index/remove/${fileItem.fileIndex.id}');
ref.invalidate(cloudFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
}
},
),
);
}
Widget _buildUnindexedListTile(
UnindexedFileItem unindexedFileItem,
WidgetRef ref,
BuildContext context,
bool isSelectionMode,
bool isSelected,
VoidCallback toggleSelection,
) {
final file = unindexedFileItem.file;
return ListTile(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode)
Checkbox(
value: isSelected,
onChanged: (value) => toggleSelection(),
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: getFileIcon(file, size: 24),
),
),
],
),
title:
file.name.isEmpty
? Text('untitled').tr().italic()
: Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(formatFileSize(file.size)),
onTap: () {
if (isSelectionMode) {
toggleSelection();
} else {
context.push('/files/${file.id}', extra: file);
}
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) {
showLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/${file.id}');
ref.invalidate(unindexedFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
}
},
),
);
}
Widget _buildWaterfallUnindexedFileTile( Widget _buildWaterfallUnindexedFileTile(
UnindexedFileItem unindexedFileItem, UnindexedFileItem unindexedFileItem,
WidgetRef ref, WidgetRef ref,
BuildContext context, BuildContext context,
bool isSelectionMode,
bool isSelected,
VoidCallback? toggleSelection,
) { ) {
return _buildWaterfallFileTileBase( return _buildWaterfallFileTileBase(
unindexedFileItem.file, unindexedFileItem.file,
@@ -965,57 +1234,9 @@ class FileListView extends HookConsumerWidget {
}, },
), ),
], ],
); isSelectionMode,
} isSelected,
toggleSelection,
Widget _buildListUnindexedFileTile(
UnindexedFileItem unindexedFileItem,
WidgetRef ref,
BuildContext context,
) {
final file = unindexedFileItem.file;
return ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: getFileIcon(file, size: 24),
),
),
title:
file.name.isEmpty
? Text('untitled').tr().italic()
: Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(formatFileSize(file.size)),
onTap: () {
context.push('/files/${file.id}', extra: file);
},
trailing: IconButton(
icon: const Icon(Symbols.delete),
onPressed: () async {
final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(),
'deleteFile'.tr(),
);
if (!confirmed) return;
if (context.mounted) {
showLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/${file.id}');
ref.invalidate(unindexedFileListNotifierProvider);
} catch (e) {
showSnackBar('failedToDeleteFile'.tr());
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
}
},
),
); );
} }

View File

@@ -457,7 +457,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
maxLines: 6, maxLines: 6,
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
), ),
), ),
); );

View File

@@ -281,8 +281,8 @@ class ComposeFundSheet extends HookConsumerWidget {
// Return the fund that was just created (but not yet paid) // Return the fund that was just created (but not yet paid)
if (context.mounted) { if (context.mounted) {
hideLoadingModal(context); hideLoadingModal(context);
}
Navigator.of(context).pop(fund); Navigator.of(context).pop(fund);
}
return; return;
} }
@@ -327,10 +327,10 @@ class ComposeFundSheet extends HookConsumerWidget {
if (context.mounted) { if (context.mounted) {
hideLoadingModal(context); hideLoadingModal(context);
}
Navigator.of( Navigator.of(
context, context,
).pop(updatedFund); ).pop(updatedFund);
}
} else { } else {
isPushing.value = false; isPushing.value = false;
} }

View File

@@ -458,7 +458,7 @@ class FundClaimDialog extends HookConsumerWidget {
// Remaining amount // Remaining amount
Text( Text(
'${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / ${remainingSplits} splits', '${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / $remainingSplits splits',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary, color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,