From 07f191171ca35bda5ff41c386f7088481f635fc0 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 15 Nov 2025 15:42:09 +0800 Subject: [PATCH] :sparkles: Waterfall layout in files (w.i.p) --- lib/screens/files/file_list.dart | 3 + lib/widgets/content/cloud_files.dart | 22 +- lib/widgets/file_list_view.dart | 447 ++++++++++++++++++++------- pubspec.lock | 8 + pubspec.yaml | 1 + 5 files changed, 361 insertions(+), 120 deletions(-) diff --git a/lib/screens/files/file_list.dart b/lib/screens/files/file_list.dart index cd90156e..a6666dde 100644 --- a/lib/screens/files/file_list.dart +++ b/lib/screens/files/file_list.dart @@ -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')), diff --git a/lib/widgets/content/cloud_files.dart b/lib/widgets/content/cloud_files.dart index 5f65951f..9f8f32ca 100644 --- a/lib/widgets/content/cloud_files.dart +++ b/lib/widgets/content/cloud_files.dart @@ -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: diff --git a/lib/widgets/file_list_view.dart b/lib/widgets/file_list_view.dart index 96b0c7e3..3d7bcc98 100644 --- a/lib/widgets/file_list_view.dart +++ b/lib/widgets/file_list_view.dart @@ -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? usage; final Map? quota; @@ -26,6 +31,7 @@ class FileListView extends HookConsumerWidget { final VoidCallback onPickAndUpload; final Function(BuildContext, ValueNotifier) onShowCreateDirectory; final ValueNotifier mode; + final ValueNotifier 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 items, + int widgetCount, + Widget endItemView, + WidgetRef ref, + BuildContext context, + ValueNotifier currentPath, + ValueNotifier 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 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( + 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 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), diff --git a/pubspec.lock b/pubspec.lock index dbc43525..466ccb2d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1075,6 +1075,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_svg: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2fc861aa..785a185f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -110,6 +110,7 @@ dependencies: record: ^6.1.2 qr_flutter: ^4.1.0 flutter_otp_text_field: ^1.5.1+1 + flutter_staggered_grid_view: ^0.7.0 flutter_popup_card: ^0.0.6 timezone: ^0.10.1