Waterfall layout in files (w.i.p)

This commit is contained in:
2025-11-15 15:42:09 +08:00
parent 4a5dac248e
commit 07f191171c
5 changed files with 361 additions and 120 deletions

View File

@@ -26,6 +26,8 @@ class FileListScreen extends HookConsumerWidget {
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
final viewMode = useState(FileListViewMode.list);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
@@ -56,6 +58,7 @@ class FileListScreen extends HookConsumerWidget {
() => _pickAndUploadFile(ref, currentPath.value),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),

View File

@@ -339,21 +339,13 @@ class CloudFileWidget extends HookConsumerWidget {
}
var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' =>
ratio == 1.0
? IntrinsicHeight(
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
)
: AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'image' => AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'video' => AspectRatio(
aspectRatio: ratio,
child:

View File

@@ -2,6 +2,7 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -13,12 +14,16 @@ import 'package:island/services/file_uploader.dart';
import 'package:island/utils/file_icon_utils.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
enum FileListMode { normal, unindexed }
enum FileListViewMode { list, waterfall }
class FileListView extends HookConsumerWidget {
final Map<String, dynamic>? usage;
final Map<String, dynamic>? quota;
@@ -26,6 +31,7 @@ class FileListView extends HookConsumerWidget {
final VoidCallback onPickAndUpload;
final Function(BuildContext, ValueNotifier<String>) onShowCreateDirectory;
final ValueNotifier<FileListMode> mode;
final ValueNotifier<FileListViewMode> viewMode;
const FileListView({
required this.usage,
@@ -34,6 +40,7 @@ class FileListView extends HookConsumerWidget {
required this.onPickAndUpload,
required this.onShowCreateDirectory,
required this.mode,
required this.viewMode,
super.key,
});
@@ -149,111 +156,14 @@ class FileListView extends HookConsumerWidget {
? SliverToBoxAdapter(
child: _buildEmptyDirectoryHint(ref, currentPath),
)
: SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = data.items[index];
return item.map(
file: (fileItem) {
final file = fileItem.fileIndex.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/${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:
(folderItem) => ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: SizedBox(
height: 48,
width: 48,
child:
const Icon(
Symbols.folder,
fill: 1,
).center(),
),
),
title: Text(
folderItem.folderName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: const Text('Folder'),
onTap: () {
// Navigate to folder
final newPath =
currentPath.value == '/'
? '/${folderItem.folderName}'
: '${currentPath.value}/${folderItem.folderName}';
currentPath.value = newPath;
},
),
unindexedFile: (unindexedFileItem) {
// This should not happen in normal mode
return const SizedBox.shrink();
},
);
},
: _buildFileListContent(
data.items,
widgetCount,
endItemView,
ref,
context,
currentPath,
viewMode,
),
),
};
@@ -325,6 +235,135 @@ class FileListView extends HookConsumerWidget {
);
}
Widget _buildFileListContent(
List<FileListItem> items,
int widgetCount,
Widget endItemView,
WidgetRef ref,
BuildContext context,
ValueNotifier<String> currentPath,
ValueNotifier<FileListViewMode> currentViewMode,
) {
return switch (currentViewMode.value) {
FileListViewMode.waterfall => SliverMasonryGrid(
gridDelegate: const SliverSimpleGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
delegate: SliverChildBuilderDelegate((context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = items[index];
return item.map(
file: (fileItem) => _buildWaterfallFileTile(fileItem, ref, context),
folder:
(folderItem) =>
_buildWaterfallFolderTile(folderItem, currentPath, context),
unindexedFile: (unindexedFileItem) {
// Should not happen
return const SizedBox.shrink();
},
);
}),
),
_ => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final item = items[index];
return item.map(
file: (fileItem) {
final file = fileItem.fileIndex.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/${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:
(folderItem) => ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: SizedBox(
height: 48,
width: 48,
child: const Icon(Symbols.folder, fill: 1).center(),
),
),
title: Text(
folderItem.folderName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: const Text('Folder'),
onTap: () {
final newPath =
currentPath.value == '/'
? '/${folderItem.folderName}'
: '${currentPath.value}/${folderItem.folderName}';
currentPath.value = newPath;
},
),
unindexedFile: (unindexedFileItem) {
// Should not happen in normal mode
return const SizedBox.shrink();
},
);
},
),
};
}
Widget _buildPathNavigation(
WidgetRef ref,
ValueNotifier<String> currentPath,
@@ -413,6 +452,27 @@ class FileListView extends HookConsumerWidget {
const Gap(8),
Expanded(child: pathContent),
if (mode.value == FileListMode.normal) ...[
IconButton(
icon: Icon(
viewMode.value == FileListViewMode.list
? Symbols.view_module
: Symbols.list,
),
onPressed:
() =>
viewMode.value =
viewMode.value == FileListViewMode.list
? FileListViewMode.waterfall
: FileListViewMode.list,
tooltip:
viewMode.value == FileListViewMode.list
? 'Switch to Waterfall View'
: 'Switch to List View',
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
IconButton(
icon: const Icon(Symbols.create_new_folder),
onPressed:
@@ -525,6 +585,183 @@ class FileListView extends HookConsumerWidget {
);
}
Widget _buildWaterfallFileTile(
FileItem fileItem,
WidgetRef ref,
BuildContext context,
) {
final file = fileItem.fileIndex.file;
final meta = file.fileMeta is Map ? (file.fileMeta as Map) : const {};
final ratio =
meta['ratio'] is num ? (meta['ratio'] as num).toDouble() : 1.0;
final itemType = file.mimeType?.split('/').first;
final tileRatio = itemType == 'image' ? ratio : 1.0;
final uri =
'${ref.read(apiClientProvider).options.baseUrl}/drive/files/${fileItem.fileIndex.id}';
Widget previewWidget;
switch (itemType) {
case 'image':
previewWidget = CloudImageWidget(
file: file,
aspectRatio: ratio,
fit: BoxFit.cover,
);
break;
case 'video':
previewWidget = CloudVideoWidget(item: file);
break;
case 'audio':
previewWidget = getFileIcon(file, size: 48);
break;
case 'text':
previewWidget = FutureBuilder<String>(
future: ref
.read(apiClientProvider)
.get(uri)
.then((response) => response.data as String),
builder:
(context, snapshot) =>
snapshot.hasData
? SingleChildScrollView(
child: Text(
snapshot.data!,
style: const TextStyle(
fontSize: 8,
fontFamily: 'monospace',
),
maxLines: 20,
overflow: TextOverflow.ellipsis,
),
)
: const Center(child: CircularProgressIndicator()),
);
break;
case 'application' when file.mimeType == 'application/pdf':
previewWidget = SfPdfViewer.network(
uri,
canShowScrollStatus: false,
canShowScrollHead: false,
enableDoubleTapZooming: false,
pageSpacing: 0,
);
break;
default:
previewWidget = getFileIcon(file, size: 48);
break;
}
return InkWell(
onTap: () {
context.push('/files/${fileItem.fileIndex.id}', extra: file);
},
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: tileRatio,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(color: Colors.white, child: previewWidget),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
file.name.isEmpty ? 'untitled'.tr() : file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: const Icon(Symbols.delete, color: Colors.white),
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 _buildWaterfallFolderTile(
FolderItem folderItem,
ValueNotifier<String> currentPath,
BuildContext context,
) {
return InkWell(
onTap: () {
final newPath =
currentPath.value == '/'
? '/${folderItem.folderName}'
: '${currentPath.value}/${folderItem.folderName}';
currentPath.value = newPath;
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.folder,
fill: 1,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const Gap(8),
Text(
folderItem.folderName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
],
),
),
);
}
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
return Card(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),