♻️ Improve file viewing experience

This commit is contained in:
2025-12-28 15:32:00 +08:00
parent 4c8f2e3251
commit a73d9f8ec0
6 changed files with 381 additions and 333 deletions

View File

@@ -1,28 +1,19 @@
import 'dart:io';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/drive/file_references.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/drive/upload_tasks.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/services/file_download.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/file_viewer_contents.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
class FileDetailScreen extends HookConsumerWidget {
@@ -155,7 +146,7 @@ class FileDetailScreen extends HookConsumerWidget {
actions.add(
IconButton(
icon: Icon(Icons.save_alt),
onPressed: () async => _saveToGallery(ref),
onPressed: () => FileDownloadService(ref).saveToGallery(item),
),
);
}
@@ -166,7 +157,8 @@ class FileDetailScreen extends HookConsumerWidget {
actions.add(
IconButton(
icon: Icon(Icons.save_alt),
onPressed: () async => _downloadFile(ref),
onPressed: () =>
FileDownloadService(ref).downloadWithProgress(item),
),
);
}
@@ -199,80 +191,6 @@ class FileDetailScreen extends HookConsumerWidget {
return actions;
}
Future<void> _saveToGallery(WidgetRef ref) async {
try {
showSnackBar('Saving image...');
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
await Gal.putImage(filePath, album: 'Solar Network');
showSnackBar('Image saved to gallery');
} else {
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('Image saved to $filePath');
}
} catch (e) {
showErrorAlert(e);
}
}
Future<void> _downloadFile(WidgetRef ref) async {
final taskNotifier = ref.read(uploadTasksProvider.notifier);
final taskId = taskNotifier.addLocalDownloadTask(item);
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},
onReceiveProgress: (count, total) {
if (total > 0) {
taskNotifier.updateDownloadProgress(taskId, count, total);
taskNotifier.updateTransmissionProgress(taskId, count / total);
}
},
);
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
taskNotifier.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads');
} catch (e) {
taskNotifier.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e);
}
}
Widget _buildContent(BuildContext context, WidgetRef ref, String serverUrl) {
final uri = '$serverUrl/drive/files/${item.id}';

View File

@@ -0,0 +1,128 @@
import 'dart:io';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:gal/gal.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/drive_task.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/drive/upload_tasks.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class FileDownloadService {
final WidgetRef ref;
FileDownloadService(this.ref);
String _getFileExtension(SnCloudFile item) {
var extName = p.extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
}
return extName.replaceFirst('.', '');
}
String _getFileName(SnCloudFile item, String extName) {
return item.name.isEmpty ? '${item.id}.$extName' : item.name;
}
Future<String> _downloadToTemp(SnCloudFile item, String extName) async {
final client = ref.read(apiClientProvider);
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
return filePath;
}
Future<void> saveToGallery(SnCloudFile item) async {
try {
showSnackBar('Saving image...');
final extName = _getFileExtension(item);
final filePath = await _downloadToTemp(item, extName);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
await Gal.putImage(filePath, album: 'Solar Network');
showSnackBar('Image saved to gallery');
} else {
await FileSaver.instance.saveFile(
name: _getFileName(item, extName),
file: File(filePath),
);
showSnackBar('Image saved to downloads');
}
} catch (e) {
showErrorAlert(e);
}
}
Future<void> downloadFile(SnCloudFile item) async {
try {
showSnackBar('Downloading file...');
final extName = _getFileExtension(item);
final filePath = await _downloadToTemp(item, extName);
await FileSaver.instance.saveFile(
name: _getFileName(item, extName),
file: File(filePath),
);
showSnackBar('File saved to downloads');
} catch (e) {
showErrorAlert(e);
}
}
Future<void> downloadWithProgress(
SnCloudFile item, {
void Function(int received, int total)? onProgress,
}) async {
final taskNotifier = ref.read(uploadTasksProvider.notifier);
final taskId = taskNotifier.addLocalDownloadTask(item);
try {
showSnackBar('Downloading file...');
final client = ref.read(apiClientProvider);
final extName = _getFileExtension(item);
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
onReceiveProgress: (count, total) {
onProgress?.call(count, total);
if (total > 0) {
taskNotifier.updateDownloadProgress(taskId, count, total);
taskNotifier.updateTransmissionProgress(taskId, count / total);
}
},
);
await FileSaver.instance.saveFile(
name: _getFileName(item, extName),
file: File(filePath),
);
taskNotifier.updateTaskStatus(taskId, DriveTaskStatus.completed);
showSnackBar('File saved to downloads');
} catch (e) {
taskNotifier.updateTaskStatus(
taskId,
DriveTaskStatus.failed,
errorMessage: e.toString(),
);
showErrorAlert(e);
}
}
}

View File

@@ -1,23 +1,16 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:dismissible_page/dismissible_page.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.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/widgets/alert.dart';
import 'package:island/services/file_download.dart';
import 'package:island/widgets/content/file_action_button.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/image_control_overlay.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 'cloud_files.dart';
@@ -39,42 +32,8 @@ class CloudFileLightbox extends HookConsumerWidget {
final showOriginal = useState(false);
Future<void> saveToGallery() async {
try {
// Show loading indicator
showSnackBar('Saving image...');
// Get the image URL
final client = ref.watch(apiClientProvider);
// Create a temporary file to save the image
final tempDir = await getTemporaryDirectory();
var extName = extension(item.name).trim();
if (extName.isEmpty) {
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
}
final filePath = '${tempDir.path}/${item.id}.$extName';
await client.download(
'/drive/files/${item.id}',
filePath,
queryParameters: {'original': true},
);
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
// Save to gallery
await Gal.putImage(filePath, album: 'Solar Network');
// Show success message
showSnackBar('Image saved to gallery');
} else {
await FileSaver.instance.saveFile(
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
file: File(filePath),
);
showSnackBar('Image saved to $filePath');
}
} catch (e) {
showErrorAlert(e);
}
void saveToGallery() {
FileDownloadService(ref).saveToGallery(item);
}
void showInfoSheet() {
@@ -86,10 +45,6 @@ class CloudFileLightbox extends HookConsumerWidget {
);
}
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
return DismissiblePage(
isFullScreen: true,
backgroundColor: Colors.transparent,
@@ -128,15 +83,9 @@ class CloudFileLightbox extends HookConsumerWidget {
Row(
children: [
if (!kIsWeb)
IconButton(
icon: Icon(
Icons.save_alt,
color: Colors.white,
shadows: shadow,
),
onPressed: () async {
saveToGallery();
},
FileActionButton.save(
onPressed: saveToGallery,
shadows: WhiteShadows.standard,
),
IconButton(
onPressed: () {
@@ -145,103 +94,46 @@ class CloudFileLightbox extends HookConsumerWidget {
icon: Icon(
showOriginal.value ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
shadows: WhiteShadows.standard,
),
),
],
),
IconButton(
icon: Icon(Icons.close, color: Colors.white, shadows: shadow),
FileActionButton.close(
onPressed: () => Navigator.of(context).pop(),
shadows: WhiteShadows.standard,
),
],
),
),
// Rotation controls
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: [
IconButton(
icon: Icon(
Icons.info_outline,
color: Colors.white,
shadows: shadow,
),
onPressed: showInfoSheet,
),
IconButton(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
icon: Icon(
Icons.more_horiz,
color: Colors.white,
shadows: shadow,
),
),
Spacer(),
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(
color: Colors.black54,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
),
],
),
onPressed: () {
rotation.value = (rotation.value + 1) % 4;
photoViewController.rotation =
rotation.value * -math.pi / 2;
},
),
],
),
ImageControlOverlay(
photoViewController: photoViewController,
rotation: rotation,
showOriginal: showOriginal.value,
onToggleQuality: () {
showOriginal.value = !showOriginal.value;
},
extraButtons: [
FileActionButton.info(
onPressed: showInfoSheet,
shadows: WhiteShadows.standard,
),
FileActionButton.more(
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop(context);
Future(() {
router.pushNamed(
'fileDetail',
pathParameters: {'id': item.id},
extra: item,
);
});
},
shadows: WhiteShadows.standard,
),
],
showExtraOnLeft: true,
),
],
),

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
enum FileActionType { save, info, more, close, custom }
class FileActionButton extends StatelessWidget {
final FileActionType type;
final IconData? icon;
final VoidCallback onPressed;
final Color? color;
final List<Shadow>? shadows;
final String? tooltip;
const FileActionButton({
super.key,
required this.type,
required this.onPressed,
this.icon,
this.color,
this.shadows,
this.tooltip,
});
factory FileActionButton.save({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.save,
icon: Icons.save_alt,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.info({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.info,
icon: Icons.info_outline,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.more({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.more,
icon: Icons.more_horiz,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
factory FileActionButton.close({
Key? key,
required VoidCallback onPressed,
Color? color,
List<Shadow>? shadows,
}) {
return FileActionButton(
key: key,
type: FileActionType.close,
icon: Icons.close,
onPressed: onPressed,
color: color ?? Colors.white,
shadows: shadows,
);
}
@override
Widget build(BuildContext context) {
final buttonIcon = icon ?? Icons.circle;
final button = IconButton(
icon: Icon(buttonIcon, color: color, shadows: shadows),
onPressed: onPressed,
);
if (tooltip != null) {
return Tooltip(message: tooltip!, child: button);
}
return button;
}
}
class WhiteShadows {
static List<Shadow> get standard => [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
}

View File

@@ -2,7 +2,6 @@ import 'dart:io';
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -11,15 +10,14 @@ 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/services/file_download.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/image_control_overlay.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';
@@ -140,10 +138,6 @@ class ImageFileContent extends HookConsumerWidget {
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(
@@ -156,8 +150,9 @@ class ImageFileContent extends HookConsumerWidget {
if (delta != null && delta != 0) {
final currentScale = photoViewController.scale ?? 1.0;
// Adjust scale based on scroll direction (invert for natural zoom)
final newScale =
delta > 0 ? currentScale * 0.9 : currentScale * 1.1;
final newScale = delta > 0
? currentScale * 0.9
: currentScale * 1.1;
// Clamp scale to reasonable bounds
final clampedScale = newScale.clamp(0.1, 10.0);
photoViewController.scale = clampedScale;
@@ -182,63 +177,13 @@ class ImageFileContent extends HookConsumerWidget {
),
),
),
// 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,
),
),
],
),
ImageControlOverlay(
photoViewController: photoViewController,
rotation: rotation,
showOriginal: showOriginal.value,
onToggleQuality: () {
showOriginal.value = !showOriginal.value;
},
),
],
);
@@ -253,10 +198,9 @@ class VideoFileContent extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var ratio =
item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
var ratio = item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
return Center(
@@ -294,34 +238,6 @@ class GenericFileContent extends HookConsumerWidget {
@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: Column(
mainAxisSize: MainAxisSize.min,
@@ -354,7 +270,7 @@ class GenericFileContent extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
onPressed: downloadFile,
onPressed: () => FileDownloadService(ref).downloadFile(item),
icon: const Icon(Symbols.download),
label: Text('download').tr(),
),

View File

@@ -0,0 +1,86 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:photo_view/photo_view.dart';
import 'package:material_symbols_icons/symbols.dart';
class ImageControlOverlay extends HookWidget {
final PhotoViewController photoViewController;
final ValueNotifier<int> rotation;
final bool showOriginal;
final VoidCallback onToggleQuality;
final List<Widget>? extraButtons;
final bool showExtraOnLeft;
const ImageControlOverlay({
super.key,
required this.photoViewController,
required this.rotation,
required this.showOriginal,
required this.onToggleQuality,
this.extraButtons,
this.showExtraOnLeft = false,
});
@override
Widget build(BuildContext context) {
final shadow = [
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
];
final controlButtons = [
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;
},
),
];
return Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 16,
right: 16,
child: Row(
children: showExtraOnLeft
? [...?extraButtons, const Spacer(), ...controlButtons]
: [
...controlButtons,
const Spacer(),
IconButton(
onPressed: onToggleQuality,
icon: Icon(
showOriginal ? Symbols.hd : Symbols.sd,
color: Colors.white,
shadows: shadow,
),
),
...?extraButtons,
],
),
);
}
}