Compare commits
7 Commits
7957e4894a
...
5f2f083d72
| Author | SHA1 | Date | |
|---|---|---|---|
|
5f2f083d72
|
|||
|
5cf40e27de
|
|||
|
1ab7295918
|
|||
|
07f191171c
|
|||
|
4a5dac248e
|
|||
|
3b983a6444
|
|||
|
4607b77355
|
@@ -1,8 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:file_saver/file_saver.dart';
|
import 'package:file_saver/file_saver.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -14,18 +11,12 @@ import 'package:island/models/file.dart';
|
|||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/utils/format.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/content/audio.dart';
|
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
|
||||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
import 'package:island/widgets/content/video.dart';
|
import 'package:island/widgets/content/file_viewer_contents.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
|
||||||
import 'package:path/path.dart' show extension;
|
import 'package:path/path.dart' show extension;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
|
||||||
|
|
||||||
class FileDetailScreen extends HookConsumerWidget {
|
class FileDetailScreen extends HookConsumerWidget {
|
||||||
final SnCloudFile item;
|
final SnCloudFile item;
|
||||||
@@ -227,312 +218,14 @@ class FileDetailScreen extends HookConsumerWidget {
|
|||||||
final uri = '$serverUrl/drive/files/${item.id}';
|
final uri = '$serverUrl/drive/files/${item.id}';
|
||||||
|
|
||||||
return switch (item.mimeType?.split('/').firstOrNull) {
|
return switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
'image' => _buildImageContent(context, ref, uri),
|
'image' => ImageFileContent(item: item, uri: uri),
|
||||||
'video' => _buildVideoContent(context, ref, uri),
|
'video' => VideoFileContent(item: item, uri: uri),
|
||||||
'audio' => _buildAudioContent(context, ref, uri),
|
'audio' => AudioFileContent(item: item, uri: uri),
|
||||||
_ when item.mimeType == 'application/pdf' => _PdfContent(uri: uri),
|
_ when item.mimeType == 'application/pdf' => PdfFileContent(uri: uri),
|
||||||
_ when item.mimeType?.startsWith('text/') == true => _TextContent(
|
_ when item.mimeType?.startsWith('text/') == true => TextFileContent(
|
||||||
uri: uri,
|
uri: uri,
|
||||||
),
|
),
|
||||||
_ => _buildGenericContent(context, ref),
|
_ => GenericFileContent(item: item),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImageContent(BuildContext context, WidgetRef ref, String uri) {
|
|
||||||
final photoViewController = useMemoized(() => PhotoViewController(), []);
|
|
||||||
final rotation = useState(0);
|
|
||||||
final showOriginal = useState(false);
|
|
||||||
|
|
||||||
final shadow = [
|
|
||||||
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
|
|
||||||
];
|
|
||||||
|
|
||||||
return DismissiblePage(
|
|
||||||
isFullScreen: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
direction: DismissiblePageDismissDirection.down,
|
|
||||||
onDismissed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: PhotoView(
|
|
||||||
backgroundDecoration: BoxDecoration(
|
|
||||||
color: Colors.black.withOpacity(0.9),
|
|
||||||
),
|
|
||||||
controller: photoViewController,
|
|
||||||
imageProvider: CloudImageWidget.provider(
|
|
||||||
fileId: item.id,
|
|
||||||
serverUrl: ref.watch(serverUrlProvider),
|
|
||||||
original: showOriginal.value,
|
|
||||||
),
|
|
||||||
customSize: MediaQuery.of(context).size,
|
|
||||||
basePosition: Alignment.center,
|
|
||||||
filterQuality: FilterQuality.high,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Controls overlay
|
|
||||||
Positioned(
|
|
||||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.remove,
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: shadow,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
photoViewController.scale =
|
|
||||||
(photoViewController.scale ?? 1) - 0.05;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
|
|
||||||
onPressed: () {
|
|
||||||
photoViewController.scale =
|
|
||||||
(photoViewController.scale ?? 1) + 0.05;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.rotate_left,
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: shadow,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
rotation.value = (rotation.value - 1) % 4;
|
|
||||||
photoViewController.rotation =
|
|
||||||
rotation.value * -math.pi / 2;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.rotate_right,
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: shadow,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
rotation.value = (rotation.value + 1) % 4;
|
|
||||||
photoViewController.rotation =
|
|
||||||
rotation.value * -math.pi / 2;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
showOriginal.value = !showOriginal.value;
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
showOriginal.value ? Symbols.hd : Symbols.sd,
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: shadow,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildVideoContent(BuildContext context, WidgetRef ref, String uri) {
|
|
||||||
var ratio =
|
|
||||||
item.fileMeta?['ratio'] is num
|
|
||||||
? item.fileMeta!['ratio'].toDouble()
|
|
||||||
: 1.0;
|
|
||||||
if (ratio == 0) ratio = 1.0;
|
|
||||||
|
|
||||||
return DismissiblePage(
|
|
||||||
isFullScreen: true,
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
direction: DismissiblePageDismissDirection.down,
|
|
||||||
onDismissed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Center(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: ratio,
|
|
||||||
child: UniversalVideo(uri: uri, autoplay: true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAudioContent(BuildContext context, WidgetRef ref, String uri) {
|
|
||||||
return DismissiblePage(
|
|
||||||
isFullScreen: true,
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
||||||
direction: DismissiblePageDismissDirection.down,
|
|
||||||
onDismissed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
|
||||||
),
|
|
||||||
child: UniversalAudio(uri: uri, filename: item.name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildGenericContent(BuildContext context, WidgetRef ref) {
|
|
||||||
Future<void> downloadFile() async {
|
|
||||||
try {
|
|
||||||
showSnackBar('Downloading file...');
|
|
||||||
|
|
||||||
final client = ref.read(apiClientProvider);
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
|
||||||
var extName = extension(item.name).trim();
|
|
||||||
if (extName.isEmpty) {
|
|
||||||
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
|
|
||||||
}
|
|
||||||
final filePath = '${tempDir.path}/${item.id}.$extName';
|
|
||||||
|
|
||||||
await client.download(
|
|
||||||
'/drive/files/${item.id}',
|
|
||||||
filePath,
|
|
||||||
queryParameters: {'original': true},
|
|
||||||
);
|
|
||||||
|
|
||||||
await FileSaver.instance.saveFile(
|
|
||||||
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
|
||||||
file: File(filePath),
|
|
||||||
);
|
|
||||||
showSnackBar('File saved to downloads');
|
|
||||||
} catch (e) {
|
|
||||||
showErrorAlert(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DismissiblePage(
|
|
||||||
isFullScreen: true,
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
||||||
direction: DismissiblePageDismissDirection.down,
|
|
||||||
onDismissed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.all(32),
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.insert_drive_file,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
Text(
|
|
||||||
formatFileSize(item.size),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(24),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: downloadFile,
|
|
||||||
icon: const Icon(Symbols.download),
|
|
||||||
label: Text('download').tr(),
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => FileInfoSheet(item: item),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Symbols.info),
|
|
||||||
label: Text('info').tr(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PdfContent extends HookConsumerWidget {
|
|
||||||
final String uri;
|
|
||||||
|
|
||||||
const _PdfContent({required this.uri});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
|
|
||||||
return pdfViewer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TextContent extends HookConsumerWidget {
|
|
||||||
final String uri;
|
|
||||||
|
|
||||||
const _TextContent({required this.uri});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final textFuture = useMemoized(
|
|
||||||
() => ref
|
|
||||||
.read(apiClientProvider)
|
|
||||||
.get(uri)
|
|
||||||
.then((response) => response.data as String),
|
|
||||||
[uri],
|
|
||||||
);
|
|
||||||
|
|
||||||
return FutureBuilder<String>(
|
|
||||||
future: textFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
return Center(child: Text('Error loading text: ${snapshot.error}'));
|
|
||||||
} else if (snapshot.hasData) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: EdgeInsets.all(20),
|
|
||||||
child: SelectableText(
|
|
||||||
snapshot.data!,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const Center(child: Text('No content'));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class FileListScreen extends HookConsumerWidget {
|
|||||||
final usageAsync = ref.watch(billingUsageProvider);
|
final usageAsync = ref.watch(billingUsageProvider);
|
||||||
final quotaAsync = ref.watch(billingQuotaProvider);
|
final quotaAsync = ref.watch(billingQuotaProvider);
|
||||||
|
|
||||||
|
final viewMode = useState(FileListViewMode.list);
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -56,6 +58,7 @@ class FileListScreen extends HookConsumerWidget {
|
|||||||
() => _pickAndUploadFile(ref, currentPath.value),
|
() => _pickAndUploadFile(ref, currentPath.value),
|
||||||
onShowCreateDirectory: _showCreateDirectoryDialog,
|
onShowCreateDirectory: _showCreateDirectoryDialog,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
|
viewMode: viewMode,
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error loading quota')),
|
error: (e, _) => Center(child: Text('Error loading quota')),
|
||||||
|
|||||||
66
lib/utils/file_icon_utils.dart
Normal file
66
lib/utils/file_icon_utils.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
import '../models/file.dart';
|
||||||
|
import '../widgets/content/cloud_files.dart';
|
||||||
|
|
||||||
|
/// Returns an appropriate icon widget for the given file based on its MIME type
|
||||||
|
Widget getFileIcon(
|
||||||
|
SnCloudFile file, {
|
||||||
|
required double size,
|
||||||
|
bool tinyPreview = true,
|
||||||
|
}) {
|
||||||
|
final itemType = file.mimeType?.split('/').firstOrNull;
|
||||||
|
final mimeType = file.mimeType ?? '';
|
||||||
|
final extension = file.name.split('.').lastOrNull?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
// For images, show the actual image thumbnail
|
||||||
|
if (itemType == 'image' && tinyPreview) {
|
||||||
|
return CloudImageWidget(file: file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return icon based on MIME type or file extension
|
||||||
|
final icon = switch ((itemType, mimeType, extension)) {
|
||||||
|
('image', _, _) => Symbols.image,
|
||||||
|
('audio', _, _) => Symbols.audio_file,
|
||||||
|
('video', _, _) => Symbols.video_file,
|
||||||
|
('application', 'application/pdf', _) => Symbols.picture_as_pdf,
|
||||||
|
('application', 'application/zip', _) => Symbols.archive,
|
||||||
|
('application', 'application/x-rar-compressed', _) => Symbols.archive,
|
||||||
|
(
|
||||||
|
'application',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
_,
|
||||||
|
) ||
|
||||||
|
('application', 'application/msword', _) => Symbols.description,
|
||||||
|
(
|
||||||
|
'application',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
_,
|
||||||
|
) ||
|
||||||
|
('application', 'application/vnd.ms-excel', _) => Symbols.table_chart,
|
||||||
|
(
|
||||||
|
'application',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
_,
|
||||||
|
) ||
|
||||||
|
('application', 'application/vnd.ms-powerpoint', _) => Symbols.slideshow,
|
||||||
|
('text', _, _) => Symbols.article,
|
||||||
|
('application', _, 'js') ||
|
||||||
|
('application', _, 'dart') ||
|
||||||
|
('application', _, 'py') ||
|
||||||
|
('application', _, 'java') ||
|
||||||
|
('application', _, 'cpp') ||
|
||||||
|
('application', _, 'c') ||
|
||||||
|
('application', _, 'cs') => Symbols.code,
|
||||||
|
('application', _, 'json') ||
|
||||||
|
('application', _, 'xml') => Symbols.data_object,
|
||||||
|
(_, _, 'md') => Symbols.article,
|
||||||
|
(_, _, 'html') => Symbols.web,
|
||||||
|
(_, _, 'css') => Symbols.css,
|
||||||
|
_ => Symbols.description, // Default icon
|
||||||
|
};
|
||||||
|
|
||||||
|
return Icon(icon, size: size, fill: 1).center();
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -14,15 +13,14 @@ import 'package:island/pods/network.dart';
|
|||||||
import 'package:island/services/time.dart';
|
import 'package:island/services/time.dart';
|
||||||
import 'package:island/utils/format.dart';
|
import 'package:island/utils/format.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/audio.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:path/path.dart' show extension;
|
import 'package:path/path.dart' show extension;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
|
||||||
import 'package:island/widgets/data_saving_gate.dart';
|
import 'package:island/widgets/data_saving_gate.dart';
|
||||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
|
|
||||||
|
import 'file_viewer_contents.dart';
|
||||||
import 'image.dart';
|
import 'image.dart';
|
||||||
import 'video.dart';
|
import 'video.dart';
|
||||||
|
|
||||||
@@ -68,8 +66,6 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (item.mimeType == 'application/pdf') {
|
if (item.mimeType == 'application/pdf') {
|
||||||
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
|
|
||||||
|
|
||||||
Future<void> downloadFile() async {
|
Future<void> downloadFile() async {
|
||||||
try {
|
try {
|
||||||
showSnackBar('Downloading file...');
|
showSnackBar('Downloading file...');
|
||||||
@@ -109,7 +105,7 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
pdfViewer,
|
PdfFileContent(uri: uri),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
@@ -205,14 +201,6 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.mimeType?.startsWith('text/') == true) {
|
if (item.mimeType?.startsWith('text/') == true) {
|
||||||
final textFuture = useMemoized(
|
|
||||||
() => ref
|
|
||||||
.read(apiClientProvider)
|
|
||||||
.get(uri)
|
|
||||||
.then((response) => response.data as String),
|
|
||||||
[uri],
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> downloadFile() async {
|
Future<void> downloadFile() async {
|
||||||
try {
|
try {
|
||||||
showSnackBar('Downloading file...');
|
showSnackBar('Downloading file...');
|
||||||
@@ -252,29 +240,9 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<String>(
|
Padding(
|
||||||
future: textFuture,
|
padding: const EdgeInsets.fromLTRB(20, 68, 20, 20),
|
||||||
builder: (context, snapshot) {
|
child: TextFileContent(uri: uri),
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error loading text: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
} else if (snapshot.hasData) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20 + 48, 20, 20),
|
|
||||||
child: SelectableText(
|
|
||||||
snapshot.data!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const Center(child: Text('No content'));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
@@ -371,15 +339,7 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
'image' =>
|
'image' => AspectRatio(
|
||||||
ratio == 1.0
|
|
||||||
? IntrinsicHeight(
|
|
||||||
child:
|
|
||||||
(useInternalGate && dataSaving && !unlocked.value)
|
|
||||||
? dataPlaceHolder(Symbols.image)
|
|
||||||
: cloudImage(),
|
|
||||||
)
|
|
||||||
: AspectRatio(
|
|
||||||
aspectRatio: ratio,
|
aspectRatio: ratio,
|
||||||
child:
|
child:
|
||||||
(useInternalGate && dataSaving && !unlocked.value)
|
(useInternalGate && dataSaving && !unlocked.value)
|
||||||
@@ -393,14 +353,7 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
? dataPlaceHolder(Symbols.play_arrow)
|
? dataPlaceHolder(Symbols.play_arrow)
|
||||||
: cloudVideo(),
|
: cloudVideo(),
|
||||||
),
|
),
|
||||||
'audio' => Center(
|
'audio' => AudioFileContent(item: item, uri: uri),
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
|
||||||
),
|
|
||||||
child: UniversalAudio(uri: uri, filename: item.name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_ => Builder(
|
_ => Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
Future<void> downloadFile() async {
|
Future<void> downloadFile() async {
|
||||||
|
|||||||
313
lib/widgets/content/file_viewer_contents.dart
Normal file
313
lib/widgets/content/file_viewer_contents.dart
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:file_saver/file_saver.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/utils/format.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/content/audio.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
|
import 'package:island/widgets/content/video.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:path/path.dart' show extension;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
|
class PdfFileContent extends HookConsumerWidget {
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const PdfFileContent({required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
|
||||||
|
return pdfViewer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextFileContent extends HookConsumerWidget {
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const TextFileContent({required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final textFuture = useMemoized(
|
||||||
|
() => ref
|
||||||
|
.read(apiClientProvider)
|
||||||
|
.get(uri)
|
||||||
|
.then((response) => response.data as String),
|
||||||
|
[uri],
|
||||||
|
);
|
||||||
|
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: textFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return Center(child: Text('Error loading text: ${snapshot.error}'));
|
||||||
|
} else if (snapshot.hasData) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: SelectableText(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Center(child: Text('No content'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const ImageFileContent({required this.item, required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final photoViewController = useMemoized(() => PhotoViewController(), []);
|
||||||
|
final rotation = useState(0);
|
||||||
|
final showOriginal = useState(false);
|
||||||
|
|
||||||
|
final shadow = [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: PhotoView(
|
||||||
|
backgroundDecoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
controller: photoViewController,
|
||||||
|
imageProvider: CloudImageWidget.provider(
|
||||||
|
fileId: item.id,
|
||||||
|
serverUrl: ref.watch(serverUrlProvider),
|
||||||
|
original: showOriginal.value,
|
||||||
|
),
|
||||||
|
customSize: MediaQuery.of(context).size,
|
||||||
|
basePosition: Alignment.center,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Controls overlay
|
||||||
|
Positioned(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.remove, color: Colors.white, shadows: shadow),
|
||||||
|
onPressed: () {
|
||||||
|
photoViewController.scale =
|
||||||
|
(photoViewController.scale ?? 1) - 0.05;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
|
||||||
|
onPressed: () {
|
||||||
|
photoViewController.scale =
|
||||||
|
(photoViewController.scale ?? 1) + 0.05;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_left,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
rotation.value = (rotation.value - 1) % 4;
|
||||||
|
photoViewController.rotation = rotation.value * -math.pi / 2;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_right,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
rotation.value = (rotation.value + 1) % 4;
|
||||||
|
photoViewController.rotation = rotation.value * -math.pi / 2;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showOriginal.value = !showOriginal.value;
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
showOriginal.value ? Symbols.hd : Symbols.sd,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const VideoFileContent({required this.item, required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
var ratio =
|
||||||
|
item.fileMeta?['ratio'] is num
|
||||||
|
? item.fileMeta!['ratio'].toDouble()
|
||||||
|
: 1.0;
|
||||||
|
if (ratio == 0) ratio = 1.0;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: ratio,
|
||||||
|
child: UniversalVideo(uri: uri, autoplay: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const AudioFileContent({required this.item, required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
||||||
|
),
|
||||||
|
child: UniversalAudio(uri: uri, filename: item.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenericFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
|
||||||
|
const GenericFileContent({required this.item, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
Future<void> downloadFile() async {
|
||||||
|
try {
|
||||||
|
showSnackBar('Downloading file...');
|
||||||
|
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
var extName = extension(item.name).trim();
|
||||||
|
if (extName.isEmpty) {
|
||||||
|
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
|
||||||
|
}
|
||||||
|
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||||
|
|
||||||
|
await client.download(
|
||||||
|
'/drive/files/${item.id}',
|
||||||
|
filePath,
|
||||||
|
queryParameters: {'original': true},
|
||||||
|
);
|
||||||
|
|
||||||
|
await FileSaver.instance.saveFile(
|
||||||
|
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||||
|
file: File(filePath),
|
||||||
|
);
|
||||||
|
showSnackBar('File saved to downloads');
|
||||||
|
} catch (e) {
|
||||||
|
showErrorAlert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(32),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.insert_drive_file,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
formatFileSize(item.size),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(24),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: downloadFile,
|
||||||
|
icon: const Icon(Symbols.download),
|
||||||
|
label: Text('download'),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => FileInfoSheet(item: item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Symbols.info),
|
||||||
|
label: Text('info'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:desktop_drop/desktop_drop.dart';
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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:gap/gap.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -10,15 +11,20 @@ import 'package:island/models/file.dart';
|
|||||||
import 'package:island/pods/file_list.dart';
|
import 'package:island/pods/file_list.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/services/file_uploader.dart';
|
import 'package:island/services/file_uploader.dart';
|
||||||
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/utils/file_icon_utils.dart';
|
||||||
import 'package:island/utils/format.dart';
|
import 'package:island/utils/format.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.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:material_symbols_icons/symbols.dart';
|
||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
enum FileListMode { normal, unindexed }
|
enum FileListMode { normal, unindexed }
|
||||||
|
|
||||||
|
enum FileListViewMode { list, waterfall }
|
||||||
|
|
||||||
class FileListView extends HookConsumerWidget {
|
class FileListView extends HookConsumerWidget {
|
||||||
final Map<String, dynamic>? usage;
|
final Map<String, dynamic>? usage;
|
||||||
final Map<String, dynamic>? quota;
|
final Map<String, dynamic>? quota;
|
||||||
@@ -26,6 +32,7 @@ class FileListView extends HookConsumerWidget {
|
|||||||
final VoidCallback onPickAndUpload;
|
final VoidCallback onPickAndUpload;
|
||||||
final Function(BuildContext, ValueNotifier<String>) onShowCreateDirectory;
|
final Function(BuildContext, ValueNotifier<String>) onShowCreateDirectory;
|
||||||
final ValueNotifier<FileListMode> mode;
|
final ValueNotifier<FileListMode> mode;
|
||||||
|
final ValueNotifier<FileListViewMode> viewMode;
|
||||||
|
|
||||||
const FileListView({
|
const FileListView({
|
||||||
required this.usage,
|
required this.usage,
|
||||||
@@ -34,6 +41,7 @@ class FileListView extends HookConsumerWidget {
|
|||||||
required this.onPickAndUpload,
|
required this.onPickAndUpload,
|
||||||
required this.onShowCreateDirectory,
|
required this.onShowCreateDirectory,
|
||||||
required this.mode,
|
required this.mode,
|
||||||
|
required this.viewMode,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,100 +70,13 @@ class FileListView extends HookConsumerWidget {
|
|||||||
? SliverToBoxAdapter(
|
? SliverToBoxAdapter(
|
||||||
child: _buildEmptyUnindexedFilesHint(ref),
|
child: _buildEmptyUnindexedFilesHint(ref),
|
||||||
)
|
)
|
||||||
: SliverList.builder(
|
: _buildUnindexedFileListContent(
|
||||||
itemCount: widgetCount,
|
data.items,
|
||||||
itemBuilder: (context, index) {
|
widgetCount,
|
||||||
if (index == widgetCount - 1) {
|
endItemView,
|
||||||
return endItemView;
|
ref,
|
||||||
}
|
context,
|
||||||
|
viewMode,
|
||||||
final item = data.items[index];
|
|
||||||
return item.map(
|
|
||||||
file: (fileItem) {
|
|
||||||
// This should not happen in unindexed mode
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
folder: (folderItem) {
|
|
||||||
// This should not happen in unindexed mode
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
unindexedFile: (unindexedFileItem) {
|
|
||||||
final file = unindexedFileItem.file;
|
|
||||||
final itemType =
|
|
||||||
file.mimeType?.split('/').firstOrNull;
|
|
||||||
return ListTile(
|
|
||||||
leading: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(
|
|
||||||
Radius.circular(8),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 48,
|
|
||||||
width: 48,
|
|
||||||
child: switch (itemType) {
|
|
||||||
'image' => CloudImageWidget(file: file),
|
|
||||||
'audio' =>
|
|
||||||
const Icon(
|
|
||||||
Symbols.audio_file,
|
|
||||||
fill: 1,
|
|
||||||
).center(),
|
|
||||||
'video' =>
|
|
||||||
const Icon(
|
|
||||||
Symbols.video_file,
|
|
||||||
fill: 1,
|
|
||||||
).center(),
|
|
||||||
_ =>
|
|
||||||
const Icon(
|
|
||||||
Symbols.body_system,
|
|
||||||
fill: 1,
|
|
||||||
).center(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_ => PagingHelperSliverView(
|
_ => PagingHelperSliverView(
|
||||||
@@ -168,130 +89,14 @@ class FileListView extends HookConsumerWidget {
|
|||||||
? SliverToBoxAdapter(
|
? SliverToBoxAdapter(
|
||||||
child: _buildEmptyDirectoryHint(ref, currentPath),
|
child: _buildEmptyDirectoryHint(ref, currentPath),
|
||||||
)
|
)
|
||||||
: SliverList.builder(
|
: _buildFileListContent(
|
||||||
itemCount: widgetCount,
|
data.items,
|
||||||
itemBuilder: (context, index) {
|
widgetCount,
|
||||||
if (index == widgetCount - 1) {
|
endItemView,
|
||||||
return endItemView;
|
ref,
|
||||||
}
|
context,
|
||||||
|
currentPath,
|
||||||
final item = data.items[index];
|
viewMode,
|
||||||
return item.map(
|
|
||||||
file: (fileItem) {
|
|
||||||
final file = fileItem.fileIndex.file;
|
|
||||||
final itemType =
|
|
||||||
file.mimeType?.split('/').firstOrNull;
|
|
||||||
return ListTile(
|
|
||||||
leading: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(
|
|
||||||
Radius.circular(8),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 48,
|
|
||||||
width: 48,
|
|
||||||
child: switch (itemType) {
|
|
||||||
'image' => CloudImageWidget(file: file),
|
|
||||||
'audio' =>
|
|
||||||
const Icon(
|
|
||||||
Symbols.audio_file,
|
|
||||||
fill: 1,
|
|
||||||
).center(),
|
|
||||||
'video' =>
|
|
||||||
const Icon(
|
|
||||||
Symbols.video_file,
|
|
||||||
fill: 1,
|
|
||||||
).center(),
|
|
||||||
_ =>
|
|
||||||
const Icon(
|
|
||||||
Symbols.body_system,
|
|
||||||
fill: 1,
|
|
||||||
).center(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -346,15 +151,14 @@ class FileListView extends HookConsumerWidget {
|
|||||||
const Gap(8),
|
const Gap(8),
|
||||||
_buildPathNavigation(ref, currentPath),
|
_buildPathNavigation(ref, currentPath),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
|
if (mode.value == FileListMode.normal && currentPath.value == '/')
|
||||||
|
_buildUnindexedFilesEntry(ref).padding(bottom: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [bodyWidget, const SliverGap(12)],
|
||||||
bodyWidget,
|
).padding(
|
||||||
const SliverGap(12),
|
horizontal:
|
||||||
if (mode.value == FileListMode.normal &&
|
viewMode.value == FileListViewMode.waterfall ? 12 : null,
|
||||||
currentPath.value == '/')
|
|
||||||
SliverToBoxAdapter(child: _buildUnindexedFilesEntry(ref)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -363,6 +167,141 @@ 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) {
|
||||||
|
// Waterfall mode
|
||||||
|
FileListViewMode.waterfall => SliverMasonryGrid(
|
||||||
|
gridDelegate: SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: isWideScreen(context) ? 340 : 240,
|
||||||
|
),
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= items.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, childCount: widgetCount),
|
||||||
|
),
|
||||||
|
// ListView mode
|
||||||
|
_ => 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(
|
Widget _buildPathNavigation(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ValueNotifier<String> currentPath,
|
ValueNotifier<String> currentPath,
|
||||||
@@ -450,6 +389,27 @@ class FileListView extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Expanded(child: pathContent),
|
Expanded(child: pathContent),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
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),
|
||||||
@@ -563,6 +523,386 @@ class FileListView extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallFileTile(
|
||||||
|
FileItem fileItem,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
return _buildWaterfallFileTileBase(
|
||||||
|
fileItem.fileIndex.file,
|
||||||
|
() => '/files/${fileItem.fileIndex.id}',
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[
|
||||||
|
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 _buildWaterfallFileTileBase(
|
||||||
|
SnCloudFile file,
|
||||||
|
String Function() getRoutePath,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
List<Widget>? actions,
|
||||||
|
) {
|
||||||
|
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 uri =
|
||||||
|
'${ref.read(apiClientProvider).options.baseUrl}/drive/files/${file.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(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
context.push(getRoutePath(), extra: file);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(8),
|
||||||
|
topRight: Radius.circular(8),
|
||||||
|
),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: ratio,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Container(color: Colors.white, child: previewWidget),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
getFileIcon(file, size: 24, tinyPreview: false),
|
||||||
|
const Gap(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
formatFileSize(file.size),
|
||||||
|
maxLines: 1,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall!.copyWith(fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (actions != null) ...actions,
|
||||||
|
],
|
||||||
|
).padding(horizontal: 16, vertical: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallFolderTile(
|
||||||
|
FolderItem folderItem,
|
||||||
|
ValueNotifier<String> currentPath,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
final newPath =
|
||||||
|
currentPath.value == '/'
|
||||||
|
? '/${folderItem.folderName}'
|
||||||
|
: '${currentPath.value}/${folderItem.folderName}';
|
||||||
|
currentPath.value = newPath;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.folder,
|
||||||
|
fill: 1,
|
||||||
|
size: 24,
|
||||||
|
color: Theme.of(context).colorScheme.primaryFixedDim,
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
folderItem.folderName,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUnindexedFileListContent(
|
||||||
|
List<FileListItem> items,
|
||||||
|
int widgetCount,
|
||||||
|
Widget endItemView,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
ValueNotifier<FileListViewMode> currentViewMode,
|
||||||
|
) {
|
||||||
|
return switch (currentViewMode.value) {
|
||||||
|
// Waterfall mode
|
||||||
|
FileListViewMode.waterfall => SliverMasonryGrid(
|
||||||
|
gridDelegate: SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: isWideScreen(context) ? 340 : 240,
|
||||||
|
),
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= items.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
return item.map(
|
||||||
|
file: (fileItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
folder: (folderItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
unindexedFile:
|
||||||
|
(unindexedFileItem) => _buildWaterfallUnindexedFileTile(
|
||||||
|
unindexedFileItem,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, childCount: widgetCount),
|
||||||
|
),
|
||||||
|
// ListView mode
|
||||||
|
_ => SliverList.builder(
|
||||||
|
itemCount: widgetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
return item.map(
|
||||||
|
file: (fileItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
folder: (folderItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
unindexedFile:
|
||||||
|
(unindexedFileItem) => _buildListUnindexedFileTile(
|
||||||
|
unindexedFileItem,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallUnindexedFileTile(
|
||||||
|
UnindexedFileItem unindexedFileItem,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
return _buildWaterfallFileTileBase(
|
||||||
|
unindexedFileItem.file,
|
||||||
|
() => '/files/${unindexedFileItem.file.id}',
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[
|
||||||
|
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/${unindexedFileItem.file.id}');
|
||||||
|
ref.invalidate(unindexedFileListNotifierProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('failedToDeleteFile'.tr());
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
|
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||||
|
|||||||
@@ -1075,6 +1075,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
flutter_svg:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ dependencies:
|
|||||||
record: ^6.1.2
|
record: ^6.1.2
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
flutter_otp_text_field: ^1.5.1+1
|
flutter_otp_text_field: ^1.5.1+1
|
||||||
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
|
|
||||||
flutter_popup_card: ^0.0.6
|
flutter_popup_card: ^0.0.6
|
||||||
timezone: ^0.10.1
|
timezone: ^0.10.1
|
||||||
|
|||||||
Reference in New Issue
Block a user