Compare commits

..

7 Commits

Author SHA1 Message Date
5f2f083d72 ♻️ Fixes and optimizations in file list 2025-11-15 16:20:05 +08:00
5cf40e27de 💄 Optimized the waterfall file list style 2025-11-15 16:05:42 +08:00
1ab7295918 💄 Optimize waterfall file list design 2025-11-15 15:54:13 +08:00
07f191171c Waterfall layout in files (w.i.p) 2025-11-15 15:42:09 +08:00
4a5dac248e ♻️ Dedicated file viewer widget 2025-11-15 15:08:49 +08:00
3b983a6444 ♻️ Refactored the file detail 2025-11-15 15:04:01 +08:00
4607b77355 ♻️ Better file icons 2025-11-15 13:36:00 +08:00
8 changed files with 976 additions and 599 deletions

View File

@@ -1,8 +1,5 @@
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:flutter/foundation.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/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.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/video.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/widgets/content/file_viewer_contents.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 FileDetailScreen extends HookConsumerWidget {
final SnCloudFile item;
@@ -227,312 +218,14 @@ class FileDetailScreen extends HookConsumerWidget {
final uri = '$serverUrl/drive/files/${item.id}';
return switch (item.mimeType?.split('/').firstOrNull) {
'image' => _buildImageContent(context, ref, uri),
'video' => _buildVideoContent(context, ref, uri),
'audio' => _buildAudioContent(context, ref, uri),
_ when item.mimeType == 'application/pdf' => _PdfContent(uri: uri),
_ when item.mimeType?.startsWith('text/') == true => _TextContent(
'image' => ImageFileContent(item: item, uri: uri),
'video' => VideoFileContent(item: item, uri: uri),
'audio' => AudioFileContent(item: item, uri: uri),
_ when item.mimeType == 'application/pdf' => PdfFileContent(uri: uri),
_ when item.mimeType?.startsWith('text/') == true => TextFileContent(
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'));
},
);
}
}

View File

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

View File

@@ -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();
}

View File

@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.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/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/audio.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.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/content/file_info_sheet.dart';
import 'file_viewer_contents.dart';
import 'image.dart';
import 'video.dart';
@@ -68,8 +66,6 @@ class CloudFileWidget extends HookConsumerWidget {
);
if (item.mimeType == 'application/pdf') {
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
Future<void> downloadFile() async {
try {
showSnackBar('Downloading file...');
@@ -109,7 +105,7 @@ class CloudFileWidget extends HookConsumerWidget {
),
child: Stack(
children: [
pdfViewer,
PdfFileContent(uri: uri),
Positioned(
top: 8,
left: 8,
@@ -205,14 +201,6 @@ class CloudFileWidget extends HookConsumerWidget {
}
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 {
try {
showSnackBar('Downloading file...');
@@ -252,29 +240,9 @@ class CloudFileWidget extends HookConsumerWidget {
),
child: Stack(
children: [
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: 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'));
},
Padding(
padding: const EdgeInsets.fromLTRB(20, 68, 20, 20),
child: TextFileContent(uri: uri),
),
Positioned(
top: 8,
@@ -371,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:
@@ -393,14 +353,7 @@ class CloudFileWidget extends HookConsumerWidget {
? dataPlaceHolder(Symbols.play_arrow)
: cloudVideo(),
),
'audio' => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
),
),
'audio' => AudioFileContent(item: item, uri: uri),
_ => Builder(
builder: (context) {
Future<void> downloadFile() async {

View 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'),
),
],
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -10,15 +11,20 @@ import 'package:island/models/file.dart';
import 'package:island/pods/file_list.dart';
import 'package:island/pods/network.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/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 +32,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 +41,7 @@ class FileListView extends HookConsumerWidget {
required this.onPickAndUpload,
required this.onShowCreateDirectory,
required this.mode,
required this.viewMode,
super.key,
});
@@ -62,100 +70,13 @@ class FileListView extends HookConsumerWidget {
? SliverToBoxAdapter(
child: _buildEmptyUnindexedFilesHint(ref),
)
: SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
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);
}
}
},
),
);
},
);
},
: _buildUnindexedFileListContent(
data.items,
widgetCount,
endItemView,
ref,
context,
viewMode,
),
),
_ => PagingHelperSliverView(
@@ -168,130 +89,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;
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();
},
);
},
: _buildFileListContent(
data.items,
widgetCount,
endItemView,
ref,
context,
currentPath,
viewMode,
),
),
};
@@ -346,15 +151,14 @@ class FileListView extends HookConsumerWidget {
const Gap(8),
_buildPathNavigation(ref, currentPath),
const Gap(8),
if (mode.value == FileListMode.normal && currentPath.value == '/')
_buildUnindexedFilesEntry(ref).padding(bottom: 12),
Expanded(
child: CustomScrollView(
slivers: [
bodyWidget,
const SliverGap(12),
if (mode.value == FileListMode.normal &&
currentPath.value == '/')
SliverToBoxAdapter(child: _buildUnindexedFilesEntry(ref)),
],
slivers: [bodyWidget, const SliverGap(12)],
).padding(
horizontal:
viewMode.value == FileListViewMode.waterfall ? 12 : null,
),
),
],
@@ -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(
WidgetRef ref,
ValueNotifier<String> currentPath,
@@ -450,6 +389,27 @@ class FileListView extends HookConsumerWidget {
),
const Gap(8),
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) ...[
IconButton(
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) {
return Card(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),

View File

@@ -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:

View File

@@ -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