diff --git a/lib/widgets/content/cloud_file_collection.dart b/lib/widgets/content/cloud_file_collection.dart index 1ef0d1a..564e835 100644 --- a/lib/widgets/content/cloud_file_collection.dart +++ b/lib/widgets/content/cloud_file_collection.dart @@ -1,20 +1,32 @@ 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 StatelessWidget { +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() { @@ -30,16 +42,43 @@ class CloudFileList extends StatelessWidget { } @override - Widget build(BuildContext context) { + 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), + constraints: BoxConstraints( + maxHeight: maxHeight, + minWidth: minWidth ?? 0, + ), child: AspectRatio( aspectRatio: calculateAspectRatio(), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16)), - child: CloudFileWidget(item: files.first), + 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); @@ -64,7 +103,25 @@ class CloudFileList extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(16)), ), - children: [for (final item in files) CloudFileWidget(item: item)], + 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]), + ); + } + }, ), ), ); @@ -81,7 +138,25 @@ class CloudFileList extends StatelessWidget { itemBuilder: (context, index) { return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(16)), - child: CloudFileWidget(item: files[index]), + 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), @@ -90,3 +165,102 @@ class CloudFileList extends StatelessWidget { ); } } + +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; + } +} diff --git a/lib/widgets/content/cloud_files.dart b/lib/widgets/content/cloud_files.dart index 153ef33..9f15337 100644 --- a/lib/widgets/content/cloud_files.dart +++ b/lib/widgets/content/cloud_files.dart @@ -12,33 +12,44 @@ import 'video.dart'; class CloudFileWidget extends ConsumerWidget { final SnCloudFile item; final BoxFit fit; + final String? heroTag; + final bool noBlurhash; const CloudFileWidget({ super.key, required this.item, this.fit = BoxFit.cover, + this.heroTag, + this.noBlurhash = false, }); @override Widget build(BuildContext context, WidgetRef ref) { final serverUrl = ref.watch(serverUrlProvider); final uri = '$serverUrl/files/${item.id}'; - switch (item.mimeType?.split('/').firstOrNull) { - case "image": - return AspectRatio( - aspectRatio: (item.fileMeta?['ratio'] ?? 1).toDouble(), - child: UniversalImage(uri: uri, blurHash: item.fileMeta?['blur']), - ); - case "video": - return AspectRatio( + + final content = switch (item.mimeType?.split('/').firstOrNull) { + "image" => AspectRatio( + aspectRatio: (item.fileMeta?['ratio'] ?? 1).toDouble(), + child: UniversalImage( + uri: uri, + blurHash: noBlurhash ? null : item.fileMeta?['blur'], + ), + ), + "video" => AspectRatio( + aspectRatio: (item.fileMeta?['ratio'] ?? 16 / 9).toDouble(), + child: UniversalVideo( + uri: uri, aspectRatio: (item.fileMeta?['ratio'] ?? 16 / 9).toDouble(), - child: UniversalVideo( - uri: uri, - aspectRatio: (item.fileMeta?['ratio'] ?? 16 / 9).toDouble(), - ), - ); - default: - return Text('Unable render for ${item.mimeType}'); + ), + ), + _ => Text('Unable render for ${item.mimeType}'), + }; + + if (heroTag != null) { + return Hero(tag: heroTag!, child: content); } + + return content; } } diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 244c7eb..08da12c 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -8,6 +10,7 @@ import 'package:island/models/post.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; +import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_files.dart'; @@ -127,6 +130,18 @@ class PostItem extends HookConsumerWidget { Text(item.publisher.nick).bold(), if (item.content?.isNotEmpty ?? false) MarkdownTextContent(content: item.content!), + if (item.attachments.isNotEmpty) + CloudFileList( + files: item.attachments, + maxWidth: math.min( + MediaQuery.of(context).size.width * 0.85, + kWideScreenWidth - 160, + ), + minWidth: math.min( + MediaQuery.of(context).size.width * 0.9, + kWideScreenWidth - 160, + ), + ).padding(top: 4), ], ), onTap: () { @@ -138,18 +153,6 @@ class PostItem extends HookConsumerWidget { ), ], ), - if (item.attachments.isNotEmpty) - Container( - margin: EdgeInsets.only(left: 48), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1, - ), - ), - child: CloudFileList(files: item.attachments), - ), PostReactionList( parentId: item.id, reactions: item.reactionsCount, diff --git a/pubspec.lock b/pubspec.lock index fac7ca3..a46fd14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -397,10 +397,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.0" dbus: dependency: transitive description: @@ -441,6 +441,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + dismissible_page: + dependency: "direct main" + description: + name: dismissible_page + sha256: "5b2316f770fe83583f770df1f6505cb19102081c5971979806e77f2e507a9958" + url: "https://pub.dev" + source: hosted + version: "1.0.2" drift: dependency: "direct main" description: @@ -775,10 +783,10 @@ packages: dependency: transitive description: name: flutter_math_fork - sha256: "284bab89b2fbf1bc3a0baf13d011c1dd324d004e35d177626b77f2fc056366ac" + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.4" flutter_native_splash: dependency: "direct main" description: @@ -1381,6 +1389,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" pixel_snap: dependency: transitive description: @@ -1734,10 +1750,10 @@ packages: dependency: transitive description: name: sqlite3_flutter_libs - sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14" + sha256: "7986c26234c0a5cf4fd83ff4ee39d4195b1f47cdb50a949ec7987ede4dcbdc2a" url: "https://pub.dev" source: hosted - version: "0.5.32" + version: "0.5.33" sqlparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4ad8b41..7fc67ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,6 +98,8 @@ dependencies: markdown_widget: ^2.3.2+8 visibility_detector: ^0.4.0+2 flutter_native_splash: ^2.4.6 + photo_view: ^0.15.0 + dismissible_page: ^1.0.2 dev_dependencies: flutter_test: