This commit is contained in:
otsmr 2025-05-30 00:09:40 +02:00
parent 4b84b3f20e
commit eea77a6f08
14 changed files with 410 additions and 323 deletions

View file

@ -81,11 +81,11 @@ class MessagesDao extends DatabaseAccessor<TwonlyDatabase>
.get();
}
Future<List<Message>> getAllStoredMediaFiles() {
Stream<List<Message>> getAllStoredMediaFiles() {
return (select(messages)
..where((t) => t.mediaStored.equals(true))
..orderBy([(t) => OrderingTerm.desc(t.sendAt)]))
.get();
.watch();
}
Future<List<Message>> getAllMessagesPendingUploadOlderThanAMinute() {

View file

@ -232,5 +232,7 @@
"additionalUsersPlusTokens": "twonly-Codes für \"Plus\"-Benutzer",
"additionalUsersFreeTokens": "twonly-Codes für \"Free\"-Benutzer",
"planNotAllowed": "In deinem aktuellen Plan kannst du keine Mediendateien versenden. Aktualisiere deinen Plan jetzt, um die Mediendatei zu senden.",
"planLimitReached": "Du hast dein Planlimit für heute erreicht. Aktualisiere deinen Plan jetzt, um die Mediendatei zu senden."
"planLimitReached": "Du hast dein Planlimit für heute erreicht. Aktualisiere deinen Plan jetzt, um die Mediendatei zu senden.",
"galleryDelete": "Datei löschen",
"galleryDetails": "Details anzeigen"
}

View file

@ -390,5 +390,7 @@
"additionalUsersPlusTokens": "twonly-Codes für \"Plus\" user",
"additionalUsersFreeTokens": "twonly-Codes für \"Free\" user",
"planLimitReached": "You have reached your plan limit for today. Upgrade your plan now to send the media file.",
"planNotAllowed": "You cannot send media files with your current tariff. Upgrade your plan now to send the media file."
"planNotAllowed": "You cannot send media files with your current tariff. Upgrade your plan now to send the media file.",
"galleryDelete": "Delete file",
"galleryDetails": "Show details"
}

View file

@ -1408,6 +1408,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'You cannot send media files with your current tariff. Upgrade your plan now to send the media file.'**
String get planNotAllowed;
/// No description provided for @galleryDelete.
///
/// In en, this message translates to:
/// **'Delete file'**
String get galleryDelete;
/// No description provided for @galleryDetails.
///
/// In en, this message translates to:
/// **'Show details'**
String get galleryDetails;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {

View file

@ -682,4 +682,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get planNotAllowed => 'In deinem aktuellen Plan kannst du keine Mediendateien versenden. Aktualisiere deinen Plan jetzt, um die Mediendatei zu senden.';
@override
String get galleryDelete => 'Datei löschen';
@override
String get galleryDetails => 'Details anzeigen';
}

View file

@ -682,4 +682,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get planNotAllowed => 'You cannot send media files with your current tariff. Upgrade your plan now to send the media file.';
@override
String get galleryDelete => 'Delete file';
@override
String get galleryDetails => 'Show details';
}

View file

@ -7,7 +7,7 @@ import 'package:twonly/src/views/chats/chat_messages_components/chat_message_ent
import 'package:twonly/src/views/chats/chat_messages_components/sliding_response.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/views/gallery/gallery_main_view.dart';
import 'package:twonly/src/views/gallery/gallery_item.dart';
class ChatListEntry extends StatefulWidget {
const ChatListEntry(

View file

@ -9,7 +9,7 @@ import 'package:twonly/src/providers/api/api.dart';
import 'package:twonly/src/providers/api/media_received.dart' as received;
import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/views/chats/media_viewer_view.dart';
import 'package:twonly/src/views/gallery/gallery_main_view.dart';
import 'package:twonly/src/views/gallery/gallery_item.dart';
class ChatMediaEntry extends StatelessWidget {
const ChatMediaEntry({

View file

@ -8,7 +8,8 @@ import 'package:twonly/src/views/components/message_send_state_icon.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/views/gallery/gallery_main_view.dart';
import 'package:twonly/src/views/gallery/gallery_item.dart';
import 'package:twonly/src/views/gallery/gallery_photo_view.dart';
import 'package:video_player/video_player.dart';
class InChatMediaViewer extends StatefulWidget {

View file

@ -18,7 +18,7 @@ import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/contact/contact_view.dart';
import 'package:twonly/src/views/gallery/gallery_main_view.dart';
import 'package:twonly/src/views/gallery/gallery_item.dart';
Color getMessageColor(Message message) {
return (message.messageOtherId == null)

View file

@ -0,0 +1,71 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/providers/api/media_send.dart' as send;
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly_database.dart';
class GalleryItem {
GalleryItem({
required this.id,
required this.messages,
required this.date,
required this.mirrorVideo,
this.imagePath,
this.videoPath,
});
final String id;
final bool mirrorVideo;
final List<Message> messages;
final DateTime date;
final File? imagePath;
final File? videoPath;
static Future<Map<int, GalleryItem>> convertFromMessages(
List<Message> messages) async {
Map<int, GalleryItem> items = {};
for (final message in messages) {
bool isSend = message.messageOtherId == null;
int id = message.mediaUploadId ?? message.messageId;
final basePath = await send.getMediaFilePath(
isSend ? message.mediaUploadId! : message.messageId,
isSend ? "send" : "received",
);
File? imagePath;
File? videoPath;
if (await File("$basePath.mp4").exists()) {
videoPath = File("$basePath.mp4");
} else if (await File("$basePath.png").exists()) {
imagePath = File("$basePath.png");
} else {
if (message.mediaStored) {
/// media file was deleted, ... remove the file
twonlyDatabase.messagesDao.updateMessageByMessageId(
message.messageId, MessagesCompanion(mediaStored: Value(false)));
}
continue;
}
bool mirrorVideo = false;
if (videoPath != null) {
MediaMessageContent content =
MediaMessageContent.fromJson(jsonDecode(message.contentJson!));
mirrorVideo = content.mirrorVideo;
}
items
.putIfAbsent(
id,
() => GalleryItem(
id: id.toString(),
messages: [],
date: message.sendAt,
mirrorVideo: mirrorVideo,
imagePath: imagePath,
videoPath: videoPath))
.messages
.add(message);
}
return items;
}
}

View file

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/views/gallery/gallery_item.dart';
import 'package:video_player/video_player.dart';
class GalleryItemThumbnail extends StatefulWidget {
const GalleryItemThumbnail({
super.key,
required this.galleryItem,
required this.onTap,
});
final GalleryItem galleryItem;
final GestureTapCallback onTap;
@override
State<GalleryItemThumbnail> createState() => _GalleryItemThumbnailState();
}
class _GalleryItemThumbnailState extends State<GalleryItemThumbnail> {
VideoPlayerController? _controller;
@override
void initState() {
super.initState();
if (widget.galleryItem.videoPath != null) {
_controller = VideoPlayerController.file(widget.galleryItem.videoPath!)
..initialize().then((_) {
setState(() {});
});
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
String formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return "$twoDigitMinutes:$twoDigitSeconds";
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
child: Hero(
tag: widget.galleryItem.id,
child: (widget.galleryItem.imagePath != null)
? Image.file(widget.galleryItem.imagePath!)
: Stack(
children: [
if (_controller != null && _controller!.value.isInitialized)
Positioned.fill(
child: AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!),
)),
if (_controller != null && _controller!.value.isInitialized)
Positioned(
bottom: 5,
right: 5,
child: Text(
_controller!.value.isInitialized
? formatDuration(_controller!.value.duration)
: '...',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold),
),
)
],
),
),
);
}
}

View file

@ -1,172 +1,13 @@
import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/providers/api/media_send.dart' as send;
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/share_image_editor_view.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:twonly/src/views/components/video_player_wrapper.dart';
import 'package:video_player/video_player.dart';
class GalleryItem {
GalleryItem({
required this.id,
required this.messages,
required this.date,
required this.mirrorVideo,
this.imagePath,
this.videoPath,
});
final String id;
final bool mirrorVideo;
final List<Message> messages;
final DateTime date;
final File? imagePath;
final File? videoPath;
static Future<Map<int, GalleryItem>> convertFromMessages(
List<Message> messages) async {
Map<int, GalleryItem> items = {};
for (final message in messages) {
bool isSend = message.messageOtherId == null;
int id = message.mediaUploadId ?? message.messageId;
final basePath = await send.getMediaFilePath(
isSend ? message.mediaUploadId! : message.messageId,
isSend ? "send" : "received",
);
File? imagePath;
File? videoPath;
if (await File("$basePath.mp4").exists()) {
videoPath = File("$basePath.mp4");
} else if (await File("$basePath.png").exists()) {
imagePath = File("$basePath.png");
} else {
if (message.mediaStored) {
/// media file was deleted, ... remove the file
twonlyDatabase.messagesDao.updateMessageByMessageId(
message.messageId, MessagesCompanion(mediaStored: Value(false)));
}
continue;
}
bool mirrorVideo = false;
if (videoPath != null) {
MediaMessageContent content =
MediaMessageContent.fromJson(jsonDecode(message.contentJson!));
mirrorVideo = content.mirrorVideo;
}
items
.putIfAbsent(
id,
() => GalleryItem(
id: id.toString(),
messages: [],
date: message.sendAt,
mirrorVideo: mirrorVideo,
imagePath: imagePath,
videoPath: videoPath))
.messages
.add(message);
}
return items;
}
}
class GalleryItemGrid {
GalleryItemGrid({
this.galleryItemIndex,
this.month,
this.hide,
});
final int? galleryItemIndex;
final String? month;
final bool? hide;
}
class GalleryItemThumbnail extends StatefulWidget {
const GalleryItemThumbnail({
super.key,
required this.galleryItem,
required this.onTap,
});
final GalleryItem galleryItem;
final GestureTapCallback onTap;
@override
State<GalleryItemThumbnail> createState() => _GalleryItemThumbnailState();
}
class _GalleryItemThumbnailState extends State<GalleryItemThumbnail> {
VideoPlayerController? _controller;
@override
void initState() {
super.initState();
if (widget.galleryItem.videoPath != null) {
_controller = VideoPlayerController.file(widget.galleryItem.videoPath!)
..initialize().then((_) {
setState(() {});
});
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
String formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return "$twoDigitMinutes:$twoDigitSeconds";
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
child: Hero(
tag: widget.galleryItem.id,
child: (widget.galleryItem.imagePath != null)
? Image.file(widget.galleryItem.imagePath!)
: Stack(
children: [
if (_controller != null && _controller!.value.isInitialized)
Positioned.fill(
child: AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!),
)),
if (_controller != null && _controller!.value.isInitialized)
Positioned(
bottom: 5,
right: 5,
child: Text(
_controller!.value.isInitialized
? formatDuration(_controller!.value.duration)
: '...',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold),
),
)
],
),
),
);
}
}
import 'package:twonly/src/views/gallery/gallery_item.dart';
import 'package:twonly/src/views/gallery/gallery_item_thumbnail.dart';
import 'package:twonly/src/views/gallery/gallery_photo_view.dart';
class GalleryMainView extends StatefulWidget {
const GalleryMainView({super.key});
@ -181,6 +22,7 @@ class GalleryMainViewState extends State<GalleryMainView> {
Map<String, List<int>> orderedByMonth = {};
List<String> months = [];
bool mounted = true;
StreamSubscription<List<Message>>? messageSub;
@override
void initState() {
@ -191,6 +33,7 @@ class GalleryMainViewState extends State<GalleryMainView> {
@override
void dispose() {
mounted = false;
messageSub?.cancel();
super.dispose();
}
@ -230,12 +73,12 @@ class GalleryMainViewState extends State<GalleryMainView> {
}
Future initAsync() async {
List<Message> storedMediaFiles =
await twonlyDatabase.messagesDao.getAllStoredMediaFiles();
Map<int, GalleryItem> items =
await GalleryItem.convertFromMessages(storedMediaFiles);
messageSub?.cancel();
Stream<List<Message>> msgStream =
twonlyDatabase.messagesDao.getAllStoredMediaFiles();
messageSub = msgStream.listen((msgs) async {
Map<int, GalleryItem> items = await GalleryItem.convertFromMessages(msgs);
// Group items by month
orderedByMonth = {};
months = [];
@ -254,6 +97,7 @@ class GalleryMainViewState extends State<GalleryMainView> {
if (mounted) {
setState(() {});
}
});
}
@override
@ -296,144 +140,17 @@ class GalleryMainViewState extends State<GalleryMainView> {
);
}
void open(BuildContext context, final int index) {
Navigator.push(
void open(BuildContext context, final int index) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GalleryPhotoViewWrapper(
galleryItems: galleryItems,
// backgroundDecoration: const BoxDecoration(
// color: Colors.black,
// ),
initialIndex: index,
scrollDirection: verticalGallery ? Axis.vertical : Axis.horizontal,
),
),
);
}
}
class GalleryPhotoViewWrapper extends StatefulWidget {
GalleryPhotoViewWrapper({
super.key,
this.loadingBuilder,
this.backgroundDecoration,
this.minScale,
this.maxScale,
this.initialIndex = 0,
required this.galleryItems,
this.scrollDirection = Axis.horizontal,
}) : pageController = PageController(initialPage: initialIndex);
final LoadingBuilder? loadingBuilder;
final BoxDecoration? backgroundDecoration;
final dynamic minScale;
final dynamic maxScale;
final int initialIndex;
final PageController pageController;
final List<GalleryItem> galleryItems;
final Axis scrollDirection;
@override
State<StatefulWidget> createState() {
return _GalleryPhotoViewWrapperState();
}
}
class _GalleryPhotoViewWrapperState extends State<GalleryPhotoViewWrapper> {
late int currentIndex = widget.initialIndex;
void onPageChanged(int index) {
setState(() {
currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: widget.backgroundDecoration,
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: Stack(
alignment: Alignment.bottomRight,
children: <Widget>[
MediaViewSizing(
bottomNavigation: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.icon(
icon: FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageEditorView(
videoFilePath:
widget.galleryItems[currentIndex].videoPath,
imageBytes: widget
.galleryItems[currentIndex].imagePath
?.readAsBytes(),
mirrorVideo: false,
useHighQuality: true,
),
),
);
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
backgroundColor: WidgetStateProperty.all<Color>(
Theme.of(context).colorScheme.primary,
)),
label: Text(
context.lang.shareImagedEditorSendImage,
style: TextStyle(fontSize: 17),
),
),
],
),
child: PhotoViewGallery.builder(
scrollPhysics: const BouncingScrollPhysics(),
builder: _buildItem,
itemCount: widget.galleryItems.length,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration,
pageController: widget.pageController,
onPageChanged: onPageChanged,
scrollDirection: widget.scrollDirection,
),
),
],
),
),
);
}
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
final GalleryItem item = widget.galleryItems[index];
return item.videoPath != null
? PhotoViewGalleryPageOptions.customChild(
child: VideoPlayerWrapper(
videoPath: item.videoPath!,
mirrorVideo: item.mirrorVideo,
),
// childSize: const Size(300, 300),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1,
heroAttributes: PhotoViewHeroAttributes(tag: item.id),
)
: PhotoViewGalleryPageOptions(
imageProvider: FileImage(item.imagePath!),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1,
heroAttributes: PhotoViewHeroAttributes(tag: item.id),
);
initAsync();
}
}

View file

@ -0,0 +1,190 @@
import 'package:drift/drift.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/providers/api/media_received.dart' as received;
import 'package:twonly/src/providers/api/media_send.dart' as send;
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/share_image_editor_view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:twonly/src/views/components/video_player_wrapper.dart';
import 'package:twonly/src/views/gallery/gallery_item.dart';
class GalleryPhotoViewWrapper extends StatefulWidget {
GalleryPhotoViewWrapper({
super.key,
this.loadingBuilder,
this.backgroundDecoration,
this.minScale,
this.maxScale,
this.initialIndex = 0,
required this.galleryItems,
this.scrollDirection = Axis.horizontal,
}) : pageController = PageController(initialPage: initialIndex);
final LoadingBuilder? loadingBuilder;
final BoxDecoration? backgroundDecoration;
final dynamic minScale;
final dynamic maxScale;
final int initialIndex;
final PageController pageController;
final List<GalleryItem> galleryItems;
final Axis scrollDirection;
@override
State<StatefulWidget> createState() {
return _GalleryPhotoViewWrapperState();
}
}
class _GalleryPhotoViewWrapperState extends State<GalleryPhotoViewWrapper> {
late int currentIndex = widget.initialIndex;
void onPageChanged(int index) {
setState(() {
currentIndex = index;
});
}
Future deleteFile() async {
List<Message> messages = widget.galleryItems[currentIndex].messages;
bool confirmed = await showAlertDialog(
context, "Are you sure?", "The image will be irrevocably deleted.");
if (!confirmed) return;
widget.galleryItems[currentIndex].imagePath?.deleteSync();
widget.galleryItems[currentIndex].videoPath?.deleteSync();
for (final message in messages) {
await twonlyDatabase.messagesDao.updateMessageByMessageId(
message.messageId,
MessagesCompanion(mediaStored: Value(false)),
);
}
widget.galleryItems.removeAt(currentIndex);
setState(() {});
await send.purgeSendMediaFiles();
await received.purgeReceivedMediaFiles();
if (context.mounted) {
Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: widget.backgroundDecoration,
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: Stack(
alignment: Alignment.bottomRight,
children: <Widget>[
MediaViewSizing(
bottomNavigation: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.icon(
icon: FaIcon(FontAwesomeIcons.solidPaperPlane),
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageEditorView(
videoFilePath:
widget.galleryItems[currentIndex].videoPath,
imageBytes: widget
.galleryItems[currentIndex].imagePath
?.readAsBytes(),
mirrorVideo: false,
useHighQuality: true,
),
),
);
},
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
EdgeInsets.symmetric(vertical: 10, horizontal: 30),
),
backgroundColor: WidgetStateProperty.all<Color>(
Theme.of(context).colorScheme.primary,
)),
label: Text(
context.lang.shareImagedEditorSendImage,
style: TextStyle(fontSize: 17),
),
),
],
),
child: Stack(
children: [
PhotoViewGallery.builder(
scrollPhysics: const BouncingScrollPhysics(),
builder: _buildItem,
itemCount: widget.galleryItems.length,
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration,
pageController: widget.pageController,
onPageChanged: onPageChanged,
scrollDirection: widget.scrollDirection,
),
Positioned(
right: 5,
child: PopupMenuButton<String>(
onSelected: (String result) {
if (result == "delete") {
deleteFile();
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'delete',
child: Text(context.lang.galleryDelete),
),
// PopupMenuItem<String>(
// value: 'details',
// child: Text(context.lang.galleryDetails),
// ),
],
),
)
],
),
),
],
),
),
);
}
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
final GalleryItem item = widget.galleryItems[index];
return item.videoPath != null
? PhotoViewGalleryPageOptions.customChild(
child: VideoPlayerWrapper(
videoPath: item.videoPath!,
mirrorVideo: item.mirrorVideo,
),
// childSize: const Size(300, 300),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1,
heroAttributes: PhotoViewHeroAttributes(tag: item.id),
)
: PhotoViewGalleryPageOptions(
imageProvider: FileImage(item.imagePath!),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 4.1,
heroAttributes: PhotoViewHeroAttributes(tag: item.id),
);
}
}