💄 Optimize cloud file list

This commit is contained in:
2025-11-02 22:34:32 +08:00
parent 52eff0fa25
commit ab4120cc22
3 changed files with 402 additions and 291 deletions

View File

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

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

View File

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