612 lines
18 KiB
Dart
612 lines
18 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:ui';
|
|
import 'package:dismissible_page/dismissible_page.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter_blurhash/flutter_blurhash.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/widgets/content/cloud_files.dart';
|
|
import 'package:island/widgets/content/cloud_file_lightbox.dart';
|
|
import 'package:island/widgets/content/sensitive.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:styled_widget/styled_widget.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
class CloudFileList extends HookConsumerWidget {
|
|
final List<SnCloudFile> files;
|
|
final double maxHeight;
|
|
final double maxWidth;
|
|
final double? minWidth;
|
|
final bool disableZoomIn;
|
|
final bool disableConstraint;
|
|
final EdgeInsets? padding;
|
|
final bool isColumn;
|
|
const CloudFileList({
|
|
super.key,
|
|
required this.files,
|
|
this.maxHeight = 560,
|
|
this.maxWidth = double.infinity,
|
|
this.minWidth,
|
|
this.disableZoomIn = false,
|
|
this.disableConstraint = false,
|
|
this.padding,
|
|
this.isColumn = false,
|
|
});
|
|
|
|
double calculateAspectRatio() {
|
|
final ratios = <double>[];
|
|
|
|
// Collect all valid ratios
|
|
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 (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
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final heroTags = useMemoized(
|
|
() => List.generate(
|
|
files.length,
|
|
(index) => 'cloud-files#${const Uuid().v4()}',
|
|
),
|
|
[files],
|
|
);
|
|
|
|
if (files.isEmpty) return const SizedBox.shrink();
|
|
|
|
if (isColumn) {
|
|
final children = <Widget>[];
|
|
const maxFiles = 2;
|
|
final filesToShow = files.take(maxFiles).toList();
|
|
|
|
for (var i = 0; i < filesToShow.length; i++) {
|
|
final file = filesToShow[i];
|
|
final isImage = file.mimeType?.startsWith('image') ?? false;
|
|
final isAudio = file.mimeType?.startsWith('audio') ?? false;
|
|
final widgetItem = ClipRRect(
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
child: _CloudFileListEntry(
|
|
file: file,
|
|
heroTag: heroTags[i],
|
|
isImage: isImage,
|
|
disableZoomIn: disableZoomIn,
|
|
onTap: () {
|
|
if (!isImage) {
|
|
return;
|
|
}
|
|
if (!disableZoomIn) {
|
|
context.pushTransparentRoute(
|
|
CloudFileLightbox(item: file, heroTag: heroTags[i]),
|
|
rootNavigator: true,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
Widget item;
|
|
if (isAudio) {
|
|
item = SizedBox(height: 120, child: widgetItem);
|
|
} else {
|
|
item = AspectRatio(
|
|
aspectRatio: file.fileMeta?['ratio'] as double? ?? 1.0,
|
|
child: widgetItem,
|
|
);
|
|
}
|
|
children.add(item);
|
|
if (i < filesToShow.length - 1) {
|
|
children.add(const Gap(8));
|
|
}
|
|
}
|
|
|
|
if (files.length > maxFiles) {
|
|
children.add(const Gap(8));
|
|
children.add(
|
|
Text(
|
|
'filesListAdditional'.plural(files.length - filesToShow.length),
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: padding ?? EdgeInsets.zero,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: children,
|
|
),
|
|
);
|
|
}
|
|
if (files.length == 1) {
|
|
final isImage = files.first.mimeType?.startsWith('image') ?? false;
|
|
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
|
|
final widgetItem = ClipRRect(
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
child: _CloudFileListEntry(
|
|
file: files.first,
|
|
heroTag: heroTags.first,
|
|
isImage: isImage,
|
|
disableZoomIn: disableZoomIn,
|
|
onTap: () {
|
|
if (!isImage) {
|
|
return;
|
|
}
|
|
if (!disableZoomIn) {
|
|
context.pushTransparentRoute(
|
|
CloudFileLightbox(item: files.first, heroTag: heroTags.first),
|
|
rootNavigator: true,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
return Container(
|
|
padding: padding,
|
|
constraints: BoxConstraints(
|
|
maxHeight: disableConstraint ? double.infinity : maxHeight,
|
|
minWidth: minWidth ?? 0,
|
|
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
|
),
|
|
height: isAudio ? 120 : null,
|
|
child:
|
|
isAudio
|
|
? widgetItem
|
|
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
|
|
);
|
|
}
|
|
|
|
final allImages =
|
|
!files.any(
|
|
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
|
|
);
|
|
|
|
if (allImages) {
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
|
|
child: AspectRatio(
|
|
aspectRatio: calculateAspectRatio(),
|
|
child: Padding(
|
|
padding: padding ?? EdgeInsets.zero,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final availableWidth =
|
|
constraints.maxWidth.isFinite
|
|
? constraints.maxWidth
|
|
: MediaQuery.of(context).size.width;
|
|
final itemExtent = math.min(
|
|
math.min(availableWidth * 0.75, maxWidth * 0.75).toDouble(),
|
|
640.0,
|
|
);
|
|
|
|
return CarouselView(
|
|
itemSnapping: true,
|
|
itemExtent: itemExtent,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
|
),
|
|
children: [
|
|
for (var i = 0; i < files.length; i++)
|
|
Stack(
|
|
children: [
|
|
_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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
onTap: (i) {
|
|
if (!(files[i].mimeType?.startsWith('image') ?? false)) {
|
|
return;
|
|
}
|
|
if (!disableZoomIn) {
|
|
context.pushTransparentRoute(
|
|
CloudFileLightbox(item: files[i], heroTag: heroTags[i]),
|
|
rootNavigator: true,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
|
|
child: AspectRatio(
|
|
aspectRatio: calculateAspectRatio(),
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: files.length,
|
|
padding: padding,
|
|
itemBuilder: (context, index) {
|
|
return AspectRatio(
|
|
aspectRatio:
|
|
files[index].fileMeta?['ratio'] is num
|
|
? files[index].fileMeta!['ratio'].toDouble()
|
|
: 1.0,
|
|
child: Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
|
child: _CloudFileListEntry(
|
|
file: files[index],
|
|
heroTag: heroTags[index],
|
|
isImage:
|
|
files[index].mimeType?.startsWith('image') ?? false,
|
|
disableZoomIn: disableZoomIn,
|
|
onTap: () {
|
|
if (!(files[index].mimeType?.startsWith('image') ??
|
|
false)) {
|
|
return;
|
|
}
|
|
if (!disableZoomIn) {
|
|
context.pushTransparentRoute(
|
|
CloudFileLightbox(
|
|
item: files[index],
|
|
heroTag: heroTags[index],
|
|
),
|
|
rootNavigator: true,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 12,
|
|
left: 16,
|
|
child: Text('${index + 1}/${files.length}')
|
|
.textColor(Colors.white)
|
|
.textShadow(
|
|
color: Colors.black54,
|
|
offset: Offset(1, 1),
|
|
blurRadius: 3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
separatorBuilder: (_, _) => const Gap(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CloudFileListEntry extends HookConsumerWidget {
|
|
final SnCloudFile file;
|
|
final String heroTag;
|
|
final bool isImage;
|
|
final bool disableZoomIn;
|
|
final VoidCallback? onTap;
|
|
|
|
const _CloudFileListEntry({
|
|
required this.file,
|
|
required this.heroTag,
|
|
required this.isImage,
|
|
required this.disableZoomIn,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final dataSaving = ref.watch(
|
|
appSettingsNotifierProvider.select((s) => s.dataSavingMode),
|
|
);
|
|
final showMature = useState(false);
|
|
final showDataSaving = useState(!dataSaving);
|
|
final lockedByDS = dataSaving && !showDataSaving.value;
|
|
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
|
|
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
|
|
|
|
final fit = BoxFit.cover;
|
|
|
|
Widget bg = const SizedBox.shrink();
|
|
if (isImage) {
|
|
if (meta['blur'] is String) {
|
|
bg = BlurHash(hash: meta['blur'] as String);
|
|
} else if (!lockedByDS && !lockedByMature) {
|
|
bg = ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
|
|
child: CloudFileWidget(
|
|
fit: BoxFit.cover,
|
|
item: file,
|
|
noBlurhash: true,
|
|
useInternalGate: false,
|
|
),
|
|
);
|
|
} else {
|
|
bg = const ColoredBox(color: Colors.black26);
|
|
}
|
|
}
|
|
|
|
final bool fullyUnlocked = !lockedByDS && !lockedByMature;
|
|
Widget fg =
|
|
fullyUnlocked
|
|
? (isImage
|
|
? CloudFileWidget(
|
|
item: file,
|
|
heroTag: heroTag,
|
|
noBlurhash: true,
|
|
fit: fit,
|
|
useInternalGate: false,
|
|
)
|
|
: CloudFileWidget(
|
|
item: file,
|
|
heroTag: heroTag,
|
|
fit: fit,
|
|
useInternalGate: false,
|
|
))
|
|
: IntrinsicWidth(
|
|
child: IntrinsicHeight(child: const SizedBox.shrink()),
|
|
);
|
|
|
|
Widget overlays;
|
|
if (lockedByDS) {
|
|
overlays = _DataSavingOverlay();
|
|
} else if (file.sensitiveMarks.isNotEmpty) {
|
|
overlays = _SensitiveOverlay(
|
|
file: file,
|
|
isRevealed: showMature.value,
|
|
onHide: () => showMature.value = false,
|
|
);
|
|
} else {
|
|
overlays = const SizedBox.shrink();
|
|
}
|
|
|
|
final content = Stack(
|
|
fit: StackFit.expand,
|
|
children: [if (isImage) Positioned.fill(child: bg), fg, overlays],
|
|
);
|
|
|
|
return InkWell(
|
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
|
onTap: () {
|
|
if (lockedByDS) {
|
|
showDataSaving.value = true;
|
|
} else if (lockedByMature) {
|
|
showMature.value = true;
|
|
} else {
|
|
onTap?.call();
|
|
}
|
|
},
|
|
child: content,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SensitiveOverlay extends StatelessWidget {
|
|
final SnCloudFile file;
|
|
final VoidCallback? onHide;
|
|
final bool isRevealed;
|
|
|
|
const _SensitiveOverlay({
|
|
required this.file,
|
|
this.onHide,
|
|
this.isRevealed = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (isRevealed) {
|
|
return Positioned(
|
|
top: 3,
|
|
left: 4,
|
|
child: IconButton(
|
|
iconSize: 16,
|
|
constraints: const BoxConstraints(),
|
|
icon: const Icon(
|
|
Icons.visibility_off,
|
|
color: Colors.white,
|
|
shadows: [
|
|
Shadow(
|
|
color: Colors.black,
|
|
blurRadius: 5.0,
|
|
offset: Offset(1.0, 1.0),
|
|
),
|
|
],
|
|
),
|
|
tooltip: 'Blur content',
|
|
onPressed: onHide,
|
|
),
|
|
);
|
|
}
|
|
|
|
return BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
child: Center(
|
|
child: _OverlayCard(
|
|
icon: Icons.warning,
|
|
title: file.sensitiveMarks
|
|
.map((e) => SensitiveCategory.values[e].i18nKey.tr())
|
|
.join(' · '),
|
|
subtitle: 'Sensitive Content',
|
|
hint: 'Tap to Reveal',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DataSavingOverlay extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ColoredBox(
|
|
color: Colors.black38,
|
|
child: Center(
|
|
child: _OverlayCard(
|
|
icon: Symbols.image,
|
|
title: 'Data Saving Mode',
|
|
subtitle: '',
|
|
hint: 'Tap to Load',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OverlayCard extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String subtitle;
|
|
final String hint;
|
|
|
|
const _OverlayCard({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.hint,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.all(12),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black54,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
constraints: const BoxConstraints(maxWidth: 280),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: Colors.white, size: 24),
|
|
const Gap(4),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
const Gap(4),
|
|
Text(hint, style: const TextStyle(color: Colors.white, fontSize: 11)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|