✨ Waterfall layout in files (w.i.p)
This commit is contained in:
@@ -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')),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user