diff --git a/lib/src/screens/feed/feed_screen.dart b/lib/src/screens/feed/feed_screen.dart index 9eee1018..346459e4 100644 --- a/lib/src/screens/feed/feed_screen.dart +++ b/lib/src/screens/feed/feed_screen.dart @@ -1027,8 +1027,8 @@ class _FeedScreenBodyState extends State itemBuilder: (context, item, index) { void onPostTap() { Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => PostPage( + PageRouteBuilder( + pageBuilder: (context, _, __) => PostPage( initData: item, onUpdate: (newValue) { var newList = _pagingController.itemList; @@ -1038,8 +1038,8 @@ class _FeedScreenBodyState extends State }); }, userCanModerate: widget.userCanModerate, - ), - ), + ) + ) ); } diff --git a/lib/src/screens/feed/post_item.dart b/lib/src/screens/feed/post_item.dart index b5f46a75..7a9906a7 100644 --- a/lib/src/screens/feed/post_item.dart +++ b/lib/src/screens/feed/post_item.dart @@ -9,6 +9,7 @@ import 'package:interstellar/src/widgets/ban_dialog.dart'; import 'package:interstellar/src/widgets/content_item/content_item.dart'; import 'package:provider/provider.dart'; import 'package:simplytranslate/simplytranslate.dart'; +import 'package:interstellar/src/widgets/super_hero.dart'; class PostItem extends StatefulWidget { const PostItem( @@ -79,211 +80,219 @@ class _PostItemState extends State { final canModerate = widget.userCanModerate || (widget.item.canAuthUserModerate ?? false); - return ContentItem( - originInstance: getNameHost(context, widget.item.user.name), - title: widget.item.title, - image: widget.item.image, - link: widget.item.url != null ? Uri.parse(widget.item.url!) : null, - body: widget.item.body, - translation: _translation, - lang: widget.item.lang, - onTranslate: (String lang) async { - await getTranslation(lang); - }, - createdAt: widget.item.createdAt, - editedAt: widget.item.editedAt, - isPreview: - widget.item.type == PostType.microblog ? false : widget.isPreview, - fullImageSize: widget.isPreview - ? switch (widget.item.type) { - PostType.thread => ac.profile.fullImageSizeThreads, - PostType.microblog => ac.profile.fullImageSizeMicroblogs, - } - : true, - showMagazineFirst: widget.item.type == PostType.thread, - read: widget.isTopLevel && widget.item.read, - isPinned: widget.item.isPinned, - isNSFW: widget.item.isNSFW, - isOC: widget.item.isOC == true, - user: widget.item.user.name, - userIcon: widget.item.user.avatar, - userIdOnClick: widget.item.user.id, - userCakeDay: widget.item.user.createdAt, - userIsBot: widget.item.user.isBot, - magazine: widget.item.magazine.name, - magazineIcon: widget.item.magazine.icon, - magazineIdOnClick: widget.item.magazine.id, - domain: widget.item.domain?.name, - domainIdOnClick: widget.item.domain?.id, - boosts: widget.item.boosts, - isBoosted: widget.item.myBoost == true, - onBoost: whenLoggedIn(context, () async { - widget.onUpdate((await ac.markAsRead( - [ - await switch (widget.item.type) { - PostType.thread => ac.api.threads.boost(widget.item.id), - PostType.microblog => - ac.api.microblogs.putVote(widget.item.id, 1), - } - ], - true, - )).first); - }), - upVotes: widget.item.upvotes, - isUpVoted: widget.item.myVote == 1, - onUpVote: whenLoggedIn(context, () async { - widget.onUpdate((await ac.markAsRead( - [ - await switch (widget.item.type) { - PostType.thread => ac.api.threads - .vote(widget.item.id, 1, widget.item.myVote == 1 ? 0 : 1), - PostType.microblog => - ac.api.microblogs.putFavorite(widget.item.id), - } - ], - true, - )).first); - }), - downVotes: widget.item.downvotes, - isDownVoted: widget.item.myVote == -1, - onDownVote: whenLoggedIn(context, () async { - widget.onUpdate((await ac.markAsRead( - [ + return SuperHero( + tag: widget.item.toString(), + child: Material( + child: ContentItem( + originInstance: getNameHost(context, widget.item.user.name), + title: widget.item.title, + image: widget.item.image, + link: widget.item.url != null ? Uri.parse(widget.item.url!) : null, + body: widget.item.body, + translation: _translation, + lang: widget.item.lang, + onTranslate: (String lang) async { + await getTranslation(lang); + }, + createdAt: widget.item.createdAt, + editedAt: widget.item.editedAt, + isPreview: + widget.item.type == PostType.microblog ? false : widget.isPreview, + fullImageSize: widget.isPreview + ? switch (widget.item.type) { + PostType.thread => ac.profile.fullImageSizeThreads, + PostType.microblog => ac.profile.fullImageSizeMicroblogs, + } + : true, + showMagazineFirst: widget.item.type == PostType.thread, + read: widget.isTopLevel && widget.item.read, + isPinned: widget.item.isPinned, + isNSFW: widget.item.isNSFW, + isOC: widget.item.isOC == true, + user: widget.item.user.name, + userIcon: widget.item.user.avatar, + userIdOnClick: widget.item.user.id, + userCakeDay: widget.item.user.createdAt, + userIsBot: widget.item.user.isBot, + magazine: widget.item.magazine.name, + magazineIcon: widget.item.magazine.icon, + magazineIdOnClick: widget.item.magazine.id, + domain: widget.item.domain?.name, + domainIdOnClick: widget.item.domain?.id, + boosts: widget.item.boosts, + isBoosted: widget.item.myBoost == true, + onBoost: whenLoggedIn(context, () async { + widget.onUpdate((await ac.markAsRead( + [ + await switch (widget.item.type) { + PostType.thread => ac.api.threads.boost(widget.item.id), + PostType.microblog => + ac.api.microblogs.putVote(widget.item.id, 1), + } + ], + true, + )) + .first); + }), + upVotes: widget.item.upvotes, + isUpVoted: widget.item.myVote == 1, + onUpVote: whenLoggedIn(context, () async { + widget.onUpdate((await ac.markAsRead( + [ + await switch (widget.item.type) { + PostType.thread => ac.api.threads + .vote(widget.item.id, 1, widget.item.myVote == 1 ? 0 : 1), + PostType.microblog => + ac.api.microblogs.putFavorite(widget.item.id), + } + ], + true, + )) + .first); + }), + downVotes: widget.item.downvotes, + isDownVoted: widget.item.myVote == -1, + onDownVote: whenLoggedIn(context, () async { + widget.onUpdate((await ac.markAsRead( + [ + await switch (widget.item.type) { + PostType.thread => ac.api.threads.vote( + widget.item.id, -1, widget.item.myVote == -1 ? 0 : -1), + PostType.microblog => + ac.api.microblogs.putVote(widget.item.id, -1), + } + ], + true, + )) + .first); + }), + contentTypeName: l(context).post, + onReply: widget.onReply, + onReport: whenLoggedIn(context, (reason) async { await switch (widget.item.type) { - PostType.thread => ac.api.threads - .vote(widget.item.id, -1, widget.item.myVote == -1 ? 0 : -1), + PostType.thread => ac.api.threads.report(widget.item.id, reason), PostType.microblog => - ac.api.microblogs.putVote(widget.item.id, -1), - } - ], - true, - )).first); - }), - contentTypeName: l(context).post, - onReply: widget.onReply, - onReport: whenLoggedIn(context, (reason) async { - await switch (widget.item.type) { - PostType.thread => ac.api.threads.report(widget.item.id, reason), - PostType.microblog => - ac.api.microblogs.report(widget.item.id, reason), - }; - }), - onEdit: widget.onEdit, - onDelete: widget.onDelete, - onMarkAsRead: () async { - widget.onUpdate( - (await ac.markAsRead([widget.item], !widget.item.read)).first); - }, - onModeratePin: !canModerate - ? null - : () async { - widget.onUpdate(await ac.api.moderation - .postPin(widget.item.type, widget.item.id)); - }, - onModerateMarkNSFW: !canModerate - ? null - : () async { - widget.onUpdate(await ac.api.moderation.postMarkNSFW( - widget.item.type, widget.item.id, !widget.item.isNSFW)); - }, - onModerateDelete: !canModerate - ? null - : () async { - widget.onUpdate(await ac.api.moderation - .postDelete(widget.item.type, widget.item.id, true)); - }, - onModerateBan: !canModerate - ? null - : () async { - await openBanDialog(context, - user: widget.item.user, magazine: widget.item.magazine); - }, - numComments: widget.item.numComments, - openLinkUri: Uri.https( - ac.instanceHost, - ac.serverSoftware == ServerSoftware.mbin - ? '/m/${widget.item.magazine.name}/${switch (widget.item.type) { - PostType.thread => 't', - PostType.microblog => 'p', - }}/${widget.item.id}' - : '/post/${widget.item.id}', - ), - editDraftResourceId: - 'edit:${widget.item.type.name}:${ac.instanceHost}:${widget.item.id}', - replyDraftResourceId: - 'reply:${widget.item.type.name}:${ac.instanceHost}:${widget.item.id}', - filterListWarnings: widget.filterListWarnings, - activeBookmarkLists: widget.item.bookmarks, - loadPossibleBookmarkLists: whenLoggedIn( - context, - () async => (await ac.api.bookmark.getBookmarkLists()) - .map((list) => list.name) - .toList(), - matchesSoftware: ServerSoftware.mbin, - ), - onAddBookmark: whenLoggedIn(context, () async { - final newBookmarks = await ac.api.bookmark.addBookmarkToDefault( - subjectType: BookmarkListSubject.fromPostType( - postType: widget.item.type, isComment: false), - subjectId: widget.item.id, - ); - widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); - }), - onAddBookmarkToList: whenLoggedIn( - context, - (String listName) async { - final newBookmarks = await ac.api.bookmark.addBookmarkToList( - subjectType: BookmarkListSubject.fromPostType( - postType: widget.item.type, isComment: false), - subjectId: widget.item.id, - listName: listName, - ); - widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); - }, - matchesSoftware: ServerSoftware.mbin, - ), - onRemoveBookmark: whenLoggedIn(context, () async { - final newBookmarks = await ac.api.bookmark.removeBookmarkFromAll( - subjectType: BookmarkListSubject.fromPostType( - postType: widget.item.type, isComment: false), - subjectId: widget.item.id, - ); - widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); - }), - onRemoveBookmarkFromList: whenLoggedIn( - context, - (String listName) async { - final newBookmarks = await ac.api.bookmark.removeBookmarkFromList( - subjectType: BookmarkListSubject.fromPostType( - postType: widget.item.type, isComment: false), - subjectId: widget.item.id, - listName: listName, - ); - widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); - }, - matchesSoftware: ServerSoftware.mbin, - ), - notificationControlStatus: widget.item.notificationControlStatus, - onNotificationControlStatusChange: widget - .item.notificationControlStatus == - null - ? null - : (newStatus) async { - await ac.api.notifications.updateControl( - targetType: switch (widget.item.type) { - PostType.thread => NotificationControlUpdateTargetType.entry, - PostType.microblog => - NotificationControlUpdateTargetType.post, + ac.api.microblogs.report(widget.item.id, reason), + }; + }), + onEdit: widget.onEdit, + onDelete: widget.onDelete, + onMarkAsRead: () async { + widget.onUpdate( + (await ac.markAsRead([widget.item], !widget.item.read)).first); + }, + onModeratePin: !canModerate + ? null + : () async { + widget.onUpdate(await ac.api.moderation + .postPin(widget.item.type, widget.item.id)); + }, + onModerateMarkNSFW: !canModerate + ? null + : () async { + widget.onUpdate(await ac.api.moderation.postMarkNSFW( + widget.item.type, widget.item.id, !widget.item.isNSFW)); + }, + onModerateDelete: !canModerate + ? null + : () async { + widget.onUpdate(await ac.api.moderation + .postDelete(widget.item.type, widget.item.id, true)); + }, + onModerateBan: !canModerate + ? null + : () async { + await openBanDialog(context, + user: widget.item.user, magazine: widget.item.magazine); }, - targetId: widget.item.id, - status: newStatus, + numComments: widget.item.numComments, + openLinkUri: Uri.https( + ac.instanceHost, + ac.serverSoftware == ServerSoftware.mbin + ? '/m/${widget.item.magazine.name}/${switch (widget.item.type) { + PostType.thread => 't', + PostType.microblog => 'p', + }}/${widget.item.id}' + : '/post/${widget.item.id}', + ), + editDraftResourceId: + 'edit:${widget.item.type.name}:${ac.instanceHost}:${widget.item.id}', + replyDraftResourceId: + 'reply:${widget.item.type.name}:${ac.instanceHost}:${widget.item.id}', + filterListWarnings: widget.filterListWarnings, + activeBookmarkLists: widget.item.bookmarks, + loadPossibleBookmarkLists: whenLoggedIn( + context, + () async => (await ac.api.bookmark.getBookmarkLists()) + .map((list) => list.name) + .toList(), + matchesSoftware: ServerSoftware.mbin, + ), + onAddBookmark: whenLoggedIn(context, () async { + final newBookmarks = await ac.api.bookmark.addBookmarkToDefault( + subjectType: BookmarkListSubject.fromPostType( + postType: widget.item.type, isComment: false), + subjectId: widget.item.id, + ); + widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); + }), + onAddBookmarkToList: whenLoggedIn( + context, + (String listName) async { + final newBookmarks = await ac.api.bookmark.addBookmarkToList( + subjectType: BookmarkListSubject.fromPostType( + postType: widget.item.type, isComment: false), + subjectId: widget.item.id, + listName: listName, ); - - widget.onUpdate( - widget.item.copyWith(notificationControlStatus: newStatus)); + widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); + }, + matchesSoftware: ServerSoftware.mbin, + ), + onRemoveBookmark: whenLoggedIn(context, () async { + final newBookmarks = await ac.api.bookmark.removeBookmarkFromAll( + subjectType: BookmarkListSubject.fromPostType( + postType: widget.item.type, isComment: false), + subjectId: widget.item.id, + ); + widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); + }), + onRemoveBookmarkFromList: whenLoggedIn( + context, + (String listName) async { + final newBookmarks = await ac.api.bookmark.removeBookmarkFromList( + subjectType: BookmarkListSubject.fromPostType( + postType: widget.item.type, isComment: false), + subjectId: widget.item.id, + listName: listName, + ); + widget.onUpdate(widget.item.copyWith(bookmarks: newBookmarks)); }, - isCompact: widget.isCompact, - onClick: widget.isTopLevel ? widget.onTap : null, + matchesSoftware: ServerSoftware.mbin, + ), + notificationControlStatus: widget.item.notificationControlStatus, + onNotificationControlStatusChange: + widget.item.notificationControlStatus == null + ? null + : (newStatus) async { + await ac.api.notifications.updateControl( + targetType: switch (widget.item.type) { + PostType.thread => + NotificationControlUpdateTargetType.entry, + PostType.microblog => + NotificationControlUpdateTargetType.post, + }, + targetId: widget.item.id, + status: newStatus, + ); + + widget.onUpdate(widget.item + .copyWith(notificationControlStatus: newStatus)); + }, + isCompact: widget.isCompact, + onClick: widget.isTopLevel ? widget.onTap : null, + ) + ) ); } } diff --git a/lib/src/widgets/content_item/content_item.dart b/lib/src/widgets/content_item/content_item.dart index 37f0db5d..0cbe7230 100644 --- a/lib/src/widgets/content_item/content_item.dart +++ b/lib/src/widgets/content_item/content_item.dart @@ -372,6 +372,7 @@ class _ContentItemState extends State { .watch() .profile .coverMediaMarkedSensitive, + hero: '${widget.magazine}${widget.user}${widget.createdAt}', ), ) : (!widget.fullImageSize @@ -383,6 +384,8 @@ class _ContentItemState extends State { fit: BoxFit.cover, openTitle: imageOpenTitle, enableBlur: widget.isNSFW, + hero: + '${widget.magazine}${widget.user}${widget.createdAt}', ), ) : AdvancedImage( @@ -390,6 +393,8 @@ class _ContentItemState extends State { openTitle: imageOpenTitle, fit: BoxFit.scaleDown, enableBlur: widget.isNSFW, + hero: + '${widget.magazine}${widget.user}${widget.createdAt}', )); final titleStyle = hasWideSize @@ -867,6 +872,7 @@ class _ContentItemState extends State { .watch() .profile .coverMediaMarkedSensitive, + hero: '${widget.magazine}${widget.user}${widget.createdAt}', ), ); diff --git a/lib/src/widgets/image.dart b/lib/src/widgets/image.dart index dc651886..45b617fc 100644 --- a/lib/src/widgets/image.dart +++ b/lib/src/widgets/image.dart @@ -9,6 +9,7 @@ import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/blur.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; import 'package:interstellar/src/widgets/wrapper.dart'; +import 'package:interstellar/src/widgets/super_hero.dart'; import 'package:material_symbols_icons/symbols.dart'; class AdvancedImage extends StatelessWidget { @@ -16,6 +17,7 @@ class AdvancedImage extends StatelessWidget { final BoxFit fit; final String? openTitle; final bool enableBlur; + final String? hero; const AdvancedImage( this.image, { @@ -23,6 +25,7 @@ class AdvancedImage extends StatelessWidget { this.fit = BoxFit.contain, this.openTitle, this.enableBlur = false, + this.hero, }); @override @@ -31,47 +34,52 @@ class AdvancedImage extends StatelessWidget { ? null : sqrt(1080 / (image.blurHashWidth! * image.blurHashHeight!)); - return Wrapper( - shouldWrap: openTitle != null, - parentBuilder: (child) => GestureDetector( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => AdvancedImagePage( - image, - title: openTitle!, - ), - ), - ); - }, - child: child, - ), + return SuperHero( + tag: image.toString() + (hero?? ''), child: Wrapper( - shouldWrap: enableBlur, - parentBuilder: (child) => Blur(child), - child: Stack( - alignment: Alignment.center, - fit: StackFit.passthrough, - children: [ - if (image.blurHash != null) - Image( - fit: fit, - image: BlurhashFfiImage( - image.blurHash!, - decodingWidth: - (blurHashSizeFactor! * image.blurHashWidth!).ceil(), - decodingHeight: - (blurHashSizeFactor * image.blurHashHeight!).ceil(), - scale: blurHashSizeFactor, + shouldWrap: openTitle != null, + parentBuilder: (child) => GestureDetector( + onTap: () { + Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (context, _, __) => AdvancedImagePage( + image, + title: openTitle!, + hero: hero, + fit: fit, ), ), - Image.network( - image.src, - fit: fit, - ), - ], + ); + }, + child: child, ), - ), + child: Wrapper( + shouldWrap: enableBlur, + parentBuilder: (child) => Blur(child), + child: Stack( + alignment: Alignment.center, + fit: StackFit.passthrough, + children: [ + if (image.blurHash != null) + Image( + fit: fit, + image: BlurhashFfiImage( + image.blurHash!, + decodingWidth: + (blurHashSizeFactor! * image.blurHashWidth!).ceil(), + decodingHeight: + (blurHashSizeFactor * image.blurHashHeight!).ceil(), + scale: blurHashSizeFactor, + ), + ), + Image.network( + image.src, + fit: fit, + ), + ], + ), + ), + ) ); } } @@ -79,8 +87,11 @@ class AdvancedImage extends StatelessWidget { class AdvancedImagePage extends StatefulWidget { final ImageModel image; final String title; + final String? hero; + final BoxFit fit; - const AdvancedImagePage(this.image, {super.key, required this.title}); + const AdvancedImagePage(this.image, + {super.key, required this.title, this.hero, this.fit = BoxFit.contain}); @override State createState() => _AdvancedImagePageState(); @@ -130,7 +141,9 @@ class _AdvancedImagePageState extends State { Positioned.fill( child: InteractiveViewer( child: SafeArea( - child: AdvancedImage(widget.image), + child: Center( + child: AdvancedImage(widget.image, + hero: widget.hero, fit: widget.fit)), ), ), ), diff --git a/lib/src/widgets/super_hero.dart b/lib/src/widgets/super_hero.dart new file mode 100644 index 00000000..90507f99 --- /dev/null +++ b/lib/src/widgets/super_hero.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +// Is workaround for having nested hero animations which is disallowed by +// default in flutter but works anyway. +class SuperHero extends Hero { + const SuperHero({ + required super.tag, + super.key, + super.createRectTween, + super.flightShuttleBuilder, + super.placeholderBuilder, + super.transitionOnUserGestures, + required super.child, + }); +} \ No newline at end of file