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 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 = []; // Collect all valid ratios for (final file in files) { final meta = file.fileMeta; if (meta is Map && 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 = {}; // 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 = []; 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 = []; 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)), ], ), ); } }