💄 Optimize cloud file list
This commit is contained in:
@@ -1,27 +1,18 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
import 'package:file_saver/file_saver.dart';
|
|
||||||
import 'package:flutter/foundation.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:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:gal/gal.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/file.dart';
|
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/widgets/alert.dart';
|
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
import 'package:island/widgets/content/cloud_file_lightbox.dart';
|
||||||
import 'package:island/widgets/content/sensitive.dart';
|
import 'package:island/widgets/content/sensitive.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_provider/path_provider.dart';
|
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
@@ -47,13 +38,100 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
double calculateAspectRatio() {
|
double calculateAspectRatio() {
|
||||||
double total = 0;
|
final ratios = <double>[];
|
||||||
for (var ratio in files.map((e) => e.fileMeta?['ratio'] ?? 1)) {
|
|
||||||
if (ratio is double) total += ratio;
|
// Collect all valid ratios
|
||||||
if (ratio is String) total += double.parse(ratio);
|
for (final file in files) {
|
||||||
|
final meta = file.fileMeta;
|
||||||
|
if (meta is Map<String, dynamic> && meta.containsKey('ratio')) {
|
||||||
|
final ratioValue = meta['ratio'];
|
||||||
|
if (ratioValue is num && ratioValue > 0) {
|
||||||
|
ratios.add(ratioValue.toDouble());
|
||||||
|
} else if (ratioValue is String) {
|
||||||
|
try {
|
||||||
|
final parsed = double.parse(ratioValue);
|
||||||
|
if (parsed > 0) ratios.add(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
// Skip invalid string ratios
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (total == 0) return 1;
|
|
||||||
return total / files.length;
|
if (ratios.isEmpty) {
|
||||||
|
// Default to 4:3 aspect ratio when no valid ratios found
|
||||||
|
return 4 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratios.length == 1) {
|
||||||
|
return ratios.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group similar ratios and find the most common one
|
||||||
|
final commonRatios = <double, int>{};
|
||||||
|
|
||||||
|
// Common aspect ratios to round to (with tolerance)
|
||||||
|
const tolerance = 0.05;
|
||||||
|
final standardRatios = [
|
||||||
|
1.0,
|
||||||
|
4 / 3,
|
||||||
|
3 / 2,
|
||||||
|
16 / 9,
|
||||||
|
5 / 3,
|
||||||
|
5 / 4,
|
||||||
|
7 / 5,
|
||||||
|
9 / 16,
|
||||||
|
2 / 3,
|
||||||
|
3 / 4,
|
||||||
|
4 / 5,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final ratio in ratios) {
|
||||||
|
// Find the closest standard ratio within tolerance
|
||||||
|
double closestRatio = ratio;
|
||||||
|
double minDiff = double.infinity;
|
||||||
|
|
||||||
|
for (final standard in standardRatios) {
|
||||||
|
final diff = (ratio - standard).abs();
|
||||||
|
if (diff < minDiff && diff <= tolerance) {
|
||||||
|
minDiff = diff;
|
||||||
|
closestRatio = standard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no standard ratio is close enough, keep original
|
||||||
|
if (minDiff == double.infinity || minDiff > tolerance) {
|
||||||
|
closestRatio = ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
commonRatios[closestRatio] = (commonRatios[closestRatio] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most frequent ratio(s)
|
||||||
|
int maxCount = 0;
|
||||||
|
final mostFrequent = <double>[];
|
||||||
|
|
||||||
|
for (final entry in commonRatios.entries) {
|
||||||
|
if (entry.value > maxCount) {
|
||||||
|
maxCount = entry.value;
|
||||||
|
mostFrequent.clear();
|
||||||
|
mostFrequent.add(entry.key);
|
||||||
|
} else if (entry.value == maxCount) {
|
||||||
|
mostFrequent.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only one most frequent ratio, return it
|
||||||
|
if (mostFrequent.length == 1) {
|
||||||
|
return mostFrequent.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple ratios have the same highest frequency, use median of them
|
||||||
|
mostFrequent.sort();
|
||||||
|
final mid = mostFrequent.length ~/ 2;
|
||||||
|
return mostFrequent.length.isEven
|
||||||
|
? (mostFrequent[mid - 1] + mostFrequent[mid]) / 2
|
||||||
|
: mostFrequent[mid];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,7 +168,7 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!disableZoomIn) {
|
if (!disableZoomIn) {
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
CloudFileZoomIn(item: file, heroTag: heroTags[i]),
|
CloudFileLightbox(item: file, heroTag: heroTags[i]),
|
||||||
rootNavigator: true,
|
rootNavigator: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -151,7 +229,7 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!disableZoomIn) {
|
if (!disableZoomIn) {
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
CloudFileZoomIn(item: files.first, heroTag: heroTags.first),
|
CloudFileLightbox(item: files.first, heroTag: heroTags.first),
|
||||||
rootNavigator: true,
|
rootNavigator: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,10 +247,7 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
child:
|
child:
|
||||||
isAudio
|
isAudio
|
||||||
? widgetItem
|
? widgetItem
|
||||||
: AspectRatio(
|
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
|
||||||
aspectRatio: calculateAspectRatio(),
|
|
||||||
child: widgetItem,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,53 +263,60 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
aspectRatio: calculateAspectRatio(),
|
aspectRatio: calculateAspectRatio(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: padding ?? EdgeInsets.zero,
|
padding: padding ?? EdgeInsets.zero,
|
||||||
child: CarouselView(
|
child: LayoutBuilder(
|
||||||
itemSnapping: true,
|
builder: (context, constraints) {
|
||||||
itemExtent: math.min(
|
final availableWidth =
|
||||||
math.min(
|
constraints.maxWidth.isFinite
|
||||||
MediaQuery.of(context).size.width * 0.75,
|
? constraints.maxWidth
|
||||||
maxWidth * 0.75,
|
: MediaQuery.of(context).size.width;
|
||||||
),
|
final itemExtent = math.min(
|
||||||
640,
|
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
|
||||||
),
|
640.0,
|
||||||
shape: RoundedRectangleBorder(
|
);
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
|
||||||
),
|
return CarouselView(
|
||||||
children: [
|
itemSnapping: true,
|
||||||
for (var i = 0; i < files.length; i++)
|
itemExtent: itemExtent,
|
||||||
Stack(
|
shape: RoundedRectangleBorder(
|
||||||
children: [
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
_CloudFileListEntry(
|
|
||||||
file: files[i],
|
|
||||||
heroTag: heroTags[i],
|
|
||||||
isImage:
|
|
||||||
files[i].mimeType?.startsWith('image') ?? false,
|
|
||||||
disableZoomIn: disableZoomIn,
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 12,
|
|
||||||
left: 16,
|
|
||||||
child: Text('${i + 1}/${files.length}')
|
|
||||||
.textColor(Colors.white)
|
|
||||||
.textShadow(
|
|
||||||
color: Colors.black54,
|
|
||||||
offset: Offset(1, 1),
|
|
||||||
blurRadius: 3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
children: [
|
||||||
onTap: (i) {
|
for (var i = 0; i < files.length; i++)
|
||||||
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
|
Stack(
|
||||||
return;
|
children: [
|
||||||
}
|
_CloudFileListEntry(
|
||||||
if (!disableZoomIn) {
|
file: files[i],
|
||||||
context.pushTransparentRoute(
|
heroTag: heroTags[i],
|
||||||
CloudFileZoomIn(item: files[i], heroTag: heroTags[i]),
|
isImage:
|
||||||
rootNavigator: true,
|
files[i].mimeType?.startsWith('image') ?? false,
|
||||||
);
|
disableZoomIn: disableZoomIn,
|
||||||
}
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 12,
|
||||||
|
left: 16,
|
||||||
|
child: Text('${i + 1}/${files.length}')
|
||||||
|
.textColor(Colors.white)
|
||||||
|
.textShadow(
|
||||||
|
color: Colors.black54,
|
||||||
|
offset: Offset(1, 1),
|
||||||
|
blurRadius: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onTap: (i) {
|
||||||
|
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!disableZoomIn) {
|
||||||
|
context.pushTransparentRoute(
|
||||||
|
CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
|
||||||
|
rootNavigator: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -273,7 +355,7 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!disableZoomIn) {
|
if (!disableZoomIn) {
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
CloudFileZoomIn(
|
CloudFileLightbox(
|
||||||
item: files[index],
|
item: files[index],
|
||||||
heroTag: heroTags[index],
|
heroTag: heroTags[index],
|
||||||
),
|
),
|
||||||
@@ -305,211 +387,6 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CloudFileZoomIn extends HookConsumerWidget {
|
|
||||||
final SnCloudFile item;
|
|
||||||
final String heroTag;
|
|
||||||
const CloudFileZoomIn({super.key, required this.item, required this.heroTag});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final serverUrl = ref.watch(serverUrlProvider);
|
|
||||||
final photoViewController = useMemoized(() => PhotoViewController(), []);
|
|
||||||
final rotation = useState(0);
|
|
||||||
|
|
||||||
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 showInfoSheet() {
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => FileInfoSheet(item: item),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
|
|
||||||
imageProvider: CloudImageWidget.provider(
|
|
||||||
fileId: item.id,
|
|
||||||
serverUrl: serverUrl,
|
|
||||||
original: showOriginal.value,
|
|
||||||
),
|
|
||||||
// Apply rotation transformation
|
|
||||||
customSize: MediaQuery.of(context).size,
|
|
||||||
basePosition: Alignment.center,
|
|
||||||
filterQuality: FilterQuality.high,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Close button and save button
|
|
||||||
Positioned(
|
|
||||||
top: MediaQuery.of(context).padding.top + 16,
|
|
||||||
right: 16,
|
|
||||||
left: 16,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (!kIsWeb)
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.save_alt,
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: shadow,
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
saveToGallery();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
showOriginal.value = !showOriginal.value;
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
showOriginal.value ? Symbols.hd : Symbols.sd,
|
|
||||||
color: Colors.white,
|
|
||||||
shadows: shadow,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.close, color: Colors.white, shadows: shadow),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CloudFileListEntry extends HookConsumerWidget {
|
class _CloudFileListEntry extends HookConsumerWidget {
|
||||||
final SnCloudFile file;
|
final SnCloudFile file;
|
||||||
final String heroTag;
|
final String heroTag;
|
||||||
@@ -535,15 +412,8 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
final lockedByDS = dataSaving && !showDataSaving.value;
|
final lockedByDS = dataSaving && !showDataSaving.value;
|
||||||
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
|
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
|
||||||
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
|
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
|
||||||
final hasRatio =
|
|
||||||
meta.containsKey('ratio') &&
|
|
||||||
(meta['ratio'] is num && (meta['ratio'] as num) != 0);
|
|
||||||
final ratio =
|
|
||||||
(meta['ratio'] is num && (meta['ratio'] as num) != 0)
|
|
||||||
? (meta['ratio'] as num).toDouble()
|
|
||||||
: 1.0;
|
|
||||||
|
|
||||||
final fit = hasRatio ? BoxFit.cover : BoxFit.contain;
|
final fit = BoxFit.cover;
|
||||||
|
|
||||||
Widget bg = const SizedBox.shrink();
|
Widget bg = const SizedBox.shrink();
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
@@ -551,7 +421,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
bg = BlurHash(hash: meta['blur'] as String);
|
bg = BlurHash(hash: meta['blur'] as String);
|
||||||
} else if (!lockedByDS && !lockedByMature) {
|
} else if (!lockedByDS && !lockedByMature) {
|
||||||
bg = ImageFiltered(
|
bg = ImageFiltered(
|
||||||
imageFilter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
imageFilter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
|
||||||
child: CloudFileWidget(
|
child: CloudFileWidget(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
item: file,
|
item: file,
|
||||||
@@ -581,7 +451,9 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
fit: fit,
|
fit: fit,
|
||||||
useInternalGate: false,
|
useInternalGate: false,
|
||||||
))
|
))
|
||||||
: AspectRatio(aspectRatio: ratio, child: const SizedBox.shrink());
|
: IntrinsicWidth(
|
||||||
|
child: IntrinsicHeight(child: const SizedBox.shrink()),
|
||||||
|
);
|
||||||
|
|
||||||
Widget overlays;
|
Widget overlays;
|
||||||
if (lockedByDS) {
|
if (lockedByDS) {
|
||||||
|
|||||||
231
lib/widgets/content/cloud_file_lightbox.dart
Normal file
231
lib/widgets/content/cloud_file_lightbox.dart
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
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: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/widgets/content/file_info_sheet.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';
|
||||||
|
|
||||||
|
class CloudFileLightbox extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String heroTag;
|
||||||
|
const CloudFileLightbox({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.heroTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
|
final photoViewController = useMemoized(() => PhotoViewController(), []);
|
||||||
|
final rotation = useState(0);
|
||||||
|
|
||||||
|
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 showInfoSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => FileInfoSheet(item: item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
|
||||||
|
imageProvider: CloudImageWidget.provider(
|
||||||
|
fileId: item.id,
|
||||||
|
serverUrl: serverUrl,
|
||||||
|
original: showOriginal.value,
|
||||||
|
),
|
||||||
|
// Apply rotation transformation
|
||||||
|
customSize: MediaQuery.of(context).size,
|
||||||
|
basePosition: Alignment.center,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Close button and save button
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 16,
|
||||||
|
right: 16,
|
||||||
|
left: 16,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (!kIsWeb)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.save_alt,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
saveToGallery();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showOriginal.value = !showOriginal.value;
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
showOriginal.value ? Symbols.hd : Symbols.sd,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close, color: Colors.white, shadows: shadow),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -371,13 +371,21 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
'image' => AspectRatio(
|
'image' =>
|
||||||
aspectRatio: ratio,
|
ratio == 1.0
|
||||||
child:
|
? IntrinsicHeight(
|
||||||
(useInternalGate && dataSaving && !unlocked.value)
|
child:
|
||||||
? dataPlaceHolder(Symbols.image)
|
(useInternalGate && dataSaving && !unlocked.value)
|
||||||
: cloudImage(),
|
? dataPlaceHolder(Symbols.image)
|
||||||
),
|
: cloudImage(),
|
||||||
|
)
|
||||||
|
: AspectRatio(
|
||||||
|
aspectRatio: ratio,
|
||||||
|
child:
|
||||||
|
(useInternalGate && dataSaving && !unlocked.value)
|
||||||
|
? dataPlaceHolder(Symbols.image)
|
||||||
|
: cloudImage(),
|
||||||
|
),
|
||||||
'video' => AspectRatio(
|
'video' => AspectRatio(
|
||||||
aspectRatio: ratio,
|
aspectRatio: ratio,
|
||||||
child:
|
child:
|
||||||
|
|||||||
Reference in New Issue
Block a user