import 'dart:math' as math; import 'dart:ui'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blurhash/flutter_blurhash.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:photo_view/photo_view.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; const CloudFileList({ super.key, required this.files, this.maxHeight = 360, this.maxWidth = double.infinity, this.minWidth, this.disableZoomIn = false, }); double calculateAspectRatio() { double total = 0; for (var ratio in files.map( (e) => e.fileMeta?['ratio'] ?? ((e.mimeType?.startsWith('image') ?? false) ? 1 : 16 / 9), )) { total += ratio; } return total / files.length; } @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 (files.length == 1) { final isImage = files.first.mimeType?.startsWith('image') ?? false; return ConstrainedBox( constraints: BoxConstraints( maxHeight: maxHeight, minWidth: minWidth ?? 0, ), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16)), child: _CloudFileListEntry( file: files.first, heroTag: heroTags.first, isImage: isImage, disableZoomIn: disableZoomIn, onTap: () { if (!isImage) { return; } if (!disableZoomIn) { context.pushTransparentRoute( CloudFileZoomIn(item: files.first, heroTag: heroTags.first), ); } }, ), ), ), ).padding(horizontal: 3); } 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: CarouselView( itemExtent: math.min( MediaQuery.of(context).size.width * 0.85, maxWidth * 0.85, ), itemSnapping: true, shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(16)), ), children: [ for (var i = 0; i < files.length; i++) _CloudFileListEntry( file: files[i], heroTag: heroTags[i], isImage: files[i].mimeType?.startsWith('image') ?? false, disableZoomIn: disableZoomIn, ), ], onTap: (i) { if (!(files[i].mimeType?.startsWith('image') ?? false)) { return; } if (!disableZoomIn) { context.pushTransparentRoute( CloudFileZoomIn(item: files[i], heroTag: heroTags[i]), ); } }, ), ), ); } return ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: files.length, padding: EdgeInsets.symmetric(horizontal: 3), itemBuilder: (context, index) { return 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( CloudFileZoomIn( item: files[index], heroTag: heroTags[index], ), ); } }, ), ); }, separatorBuilder: (_, __) => const Gap(8), ), ), ); } } 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(), []); 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, ), ), ), // Close button Positioned( top: MediaQuery.of(context).padding.top + 20, right: 20, child: IconButton( icon: Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.of(context).pop(), ), ), ], ), ); } } class _CloudFileListEntry extends StatelessWidget { 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) { final content = Stack( children: [ if (isImage) Positioned.fill( child: file.fileMeta?['blur'] != null ? BlurHash(hash: file.fileMeta?['blur']) : ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: CloudFileWidget(item: file, noBlurhash: true), ), ), if (isImage) CloudFileWidget( item: file, heroTag: heroTag, noBlurhash: true, ).center() else CloudFileWidget(item: file, heroTag: heroTag), ], ); if (onTap != null) { return InkWell( borderRadius: const BorderRadius.all(Radius.circular(16)), onTap: onTap, child: content, ); } return content; } }