diff --git a/lib/src/features/comment/data/models/thunder_comment.dart b/lib/src/features/comment/data/models/thunder_comment.dart index 6fb73aa50..40cfd0a2e 100644 --- a/lib/src/features/comment/data/models/thunder_comment.dart +++ b/lib/src/features/comment/data/models/thunder_comment.dart @@ -1,9 +1,11 @@ +import 'package:equatable/equatable.dart'; + import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/subscription_status.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; -class ThunderComment { +class ThunderComment extends Equatable { /// The comment's ID final int id; @@ -97,7 +99,7 @@ class ThunderComment { /// Whether the comment is read (comment reply/mention) final bool? read; - ThunderComment({ + const ThunderComment({ required this.id, required this.creatorId, this.replyMentionId, @@ -131,6 +133,41 @@ class ThunderComment { this.read, }); + @override + List get props => [ + id, + creatorId, + replyMentionId, + postId, + content, + removed, + published, + updated, + deleted, + apId, + local, + path, + distinguished, + languageId, + recipient, + creator, + post, + community, + score, + upvotes, + downvotes, + childCount, + creatorBannedFromCommunity, + bannedFromCommunity, + creatorIsModerator, + creatorIsAdmin, + subscribed, + saved, + creatorBlocked, + myVote, + read, + ]; + ThunderComment copyWith({ int? id, int? creatorId, diff --git a/lib/src/features/community/data/models/thunder_community.dart b/lib/src/features/community/data/models/thunder_community.dart index bc9234a97..0ff4efc77 100644 --- a/lib/src/features/community/data/models/thunder_community.dart +++ b/lib/src/features/community/data/models/thunder_community.dart @@ -1,6 +1,8 @@ +import 'package:equatable/equatable.dart'; + import 'package:thunder/src/core/enums/subscription_status.dart'; -class ThunderCommunity { +class ThunderCommunity extends Equatable { /// The community's ID final int id; @@ -88,7 +90,7 @@ class ThunderCommunity { /// The number of users active in the last half year final int? usersActiveHalfYear; - ThunderCommunity({ + const ThunderCommunity({ required this.id, required this.name, required this.title, @@ -119,6 +121,38 @@ class ThunderCommunity { this.usersActiveHalfYear, }); + @override + List get props => [ + id, + name, + title, + description, + removed, + published, + updated, + deleted, + nsfw, + actorId, + local, + icon, + banner, + hidden, + postingRestrictedToMods, + instanceId, + visibility, + subscribed, + blocked, + bannedFromCommunity, + subscribers, + subscribersLocal, + posts, + comments, + usersActiveDay, + usersActiveWeek, + usersActiveMonth, + usersActiveHalfYear, + ]; + factory ThunderCommunity.fromLemmyCommunity(Map community, {SubscriptionStatus? subscribed}) { return ThunderCommunity( id: community['id'], diff --git a/lib/src/features/community/presentation/widgets/community_header/community_header.dart b/lib/src/features/community/presentation/widgets/community_header/community_header.dart index f39037d99..849e0be9d 100644 --- a/lib/src/features/community/presentation/widgets/community_header/community_header.dart +++ b/lib/src/features/community/presentation/widgets/community_header/community_header.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; - import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/font_scale.dart'; import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/shared/images/image_preview.dart'; import 'package:thunder/src/shared/widgets/avatars/community_avatar.dart'; import 'package:thunder/src/shared/full_name_widgets.dart'; import 'package:thunder/src/shared/icon_text.dart'; @@ -217,13 +216,9 @@ class _BannerImage extends StatelessWidget { @override Widget build(BuildContext context) { return Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider(url), - fit: BoxFit.cover, - ), - ), + child: ImagePreview( + url: url, + fit: BoxFit.cover, ), ); } diff --git a/lib/src/features/feed/presentation/bloc/feed_bloc.dart b/lib/src/features/feed/presentation/bloc/feed_bloc.dart index 00516e275..90895e92a 100644 --- a/lib/src/features/feed/presentation/bloc/feed_bloc.dart +++ b/lib/src/features/feed/presentation/bloc/feed_bloc.dart @@ -171,20 +171,22 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyVotePost(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); updatedPost = await postRepository.vote(post, event.value); - state.posts[existingPostIndex] = updatedPost; + updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; - emit(state.copyWith(status: FeedStatus.success)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.save: // Optimistically save the post @@ -193,20 +195,22 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallySavePost(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); updatedPost = await postRepository.save(post, event.value); - state.posts[existingPostIndex] = updatedPost; + updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; - emit(state.copyWith(status: FeedStatus.success)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.read: // Optimistically read the post @@ -220,22 +224,24 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyReadPost(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.read(post.id, event.value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.multiRead: List eventPostIds = event.postIds ?? []; @@ -257,30 +263,32 @@ class FeedBloc extends Bloc { } try { + List updatedPosts = List.from(state.posts); for (int i = 0; i < existingPostIndexes.length; i++) { ThunderPost updatedPost = optimisticallyReadPost(posts[i], event.value); - state.posts[existingPostIndexes[i]] = updatedPost; + updatedPosts[existingPostIndexes[i]] = updatedPost; } // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); List failed = await postRepository.readMultiple(postIds, event.value); if (failed.isEmpty) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful + List restoredPosts = List.from(state.posts); for (int i = 0; i < failed.length; i++) { - state.posts[existingPostIndexes[failed[i]]] = originalPosts[failed[i]]; + restoredPosts[existingPostIndexes[failed[i]]] = originalPosts[failed[i]]; } - return emit(state.copyWith(status: FeedStatus.failure)); + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents // They will all be restored, but this is an unlikely scenario + List restoredPosts = List.from(state.posts); for (int i = 0; i < existingPostIndexes.length; i++) { - state.posts[existingPostIndexes[i]] = originalPosts[i]; + restoredPosts[existingPostIndexes[i]] = originalPosts[i]; } - return emit(state.copyWith(status: FeedStatus.failure)); + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } } case PostAction.hide: @@ -290,22 +298,24 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyHidePost(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.hide(post.id, event.value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.delete: // Optimistically delete the post @@ -314,22 +324,24 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyDeletePost(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.delete(post.id, event.value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.report: int existingPostIndex = state.posts.indexWhere((ThunderPost post) => post.id == event.postId); @@ -348,22 +360,24 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyLockPost(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.lock(post.id, event.value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.pinCommunity: // Optimistically pin the post to the community @@ -372,22 +386,24 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyPinPostToCommunity(post, event.value); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.pinCommunity(post.id, event.value); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.remove: // Optimistically remove the post from the community @@ -396,22 +412,24 @@ class FeedBloc extends Bloc { try { ThunderPost updatedPost = optimisticallyRemovePost(post, event.value['remove']); - state.posts[existingPostIndex] = updatedPost; + List updatedPosts = List.from(state.posts); + updatedPosts[existingPostIndex] = updatedPost; // Emit the state to update UI immediately - emit(state.copyWith(status: FeedStatus.success)); - emit(state.copyWith(status: FeedStatus.fetching)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); bool success = await postRepository.remove(post.id, event.value['remove'], event.value['reason']); if (success) return emit(state.copyWith(status: FeedStatus.success)); // Restore the original post contents if not successful - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } catch (e) { // Restore the original post contents - state.posts[existingPostIndex] = post; - return emit(state.copyWith(status: FeedStatus.failure)); + List restoredPosts = List.from(state.posts); + restoredPosts[existingPostIndex] = post; + return emit(state.copyWith(status: FeedStatus.failure, posts: restoredPosts)); } case PostAction.pinInstance: case PostAction.purge: @@ -421,16 +439,15 @@ class FeedBloc extends Bloc { /// Handles updating a given item within the feed Future _onFeedItemUpdated(FeedItemUpdatedEvent event, Emitter emit) async { - emit(state.copyWith(status: FeedStatus.fetching)); - // TODO: Add support for updating comments (for user profile) + List updatedPosts = List.from(state.posts); for (final (index, post) in state.posts.indexed) { if (post.id == event.post.id) { - state.posts[index] = event.post; + updatedPosts[index] = event.post; } } - emit(state.copyWith(status: FeedStatus.success, posts: state.posts)); + emit(state.copyWith(status: FeedStatus.success, posts: updatedPosts)); } /// Handles updating information about a community diff --git a/lib/src/features/feed/presentation/pages/feed_page.dart b/lib/src/features/feed/presentation/pages/feed_page.dart index fd68f17e8..085a1eee1 100644 --- a/lib/src/features/feed/presentation/pages/feed_page.dart +++ b/lib/src/features/feed/presentation/pages/feed_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; @@ -176,46 +177,52 @@ class _FeedViewState extends State { double _previousScrollPosition = 0.0; /// Minimum scroll delta before we consider it a direction change - static const double _scrollThreshold = 50.0; + static const double _scrollThreshold = 100.0; + + /// Cache the hideBottomBarOnScroll setting to avoid repeated bloc reads + bool? _cachedHideBottomBarOnScroll; @override void initState() { super.initState(); - _scrollController.addListener(() { - // Fetches new posts when the user has scrolled past 70% list - if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent * 0.7 && context.read().state.status != FeedStatus.fetching) { - context.read().add(FeedFetchedEvent(feedTypeSubview: selectedUserOption[0] ? FeedTypeSubview.post : FeedTypeSubview.comment)); - } + _scrollController.addListener(_onScroll); + BackButtonInterceptor.add(_handleBack); + } + + void _onScroll() { + // Fetches new posts when the user has scrolled past 70% list + if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent * 0.7 && context.read().state.status != FeedStatus.fetching) { + context.read().add(FeedFetchedEvent(feedTypeSubview: selectedUserOption[0] ? FeedTypeSubview.post : FeedTypeSubview.comment)); + } - // Detect scroll direction for bottom nav bar visibility - final currentScrollPosition = _scrollController.position.pixels; - final delta = currentScrollPosition - _previousScrollPosition; + // Detect scroll direction for bottom nav bar visibility + final currentScrollPosition = _scrollController.position.pixels; + final delta = currentScrollPosition - _previousScrollPosition; - if (delta.abs() > _scrollThreshold) { - final bloc = context.read(); + if (delta.abs() > _scrollThreshold) { + _cachedHideBottomBarOnScroll ??= context.read().state.hideBottomBarOnScroll; - if (bloc.state.hideBottomBarOnScroll) { - final isScrollingDown = delta > 0; - final isBottomNavBarVisible = bloc.state.isBottomNavBarVisible; - - // Only dispatch if the visibility state needs to change - // Show nav bar when scrolling up, hide when scrolling down - if (isScrollingDown && isBottomNavBarVisible) { - bloc.add(const OnBottomNavBarVisibilityChange(false)); - } else if (!isScrollingDown && !isBottomNavBarVisible) { - bloc.add(const OnBottomNavBarVisibilityChange(true)); - } + if (_cachedHideBottomBarOnScroll == true) { + final bloc = context.read(); + final isScrollingDown = delta > 0; + final isBottomNavBarVisible = bloc.state.isBottomNavBarVisible; + + // Only dispatch if the visibility state needs to change + // Show nav bar when scrolling up, hide when scrolling down + if (isScrollingDown && isBottomNavBarVisible) { + bloc.add(const OnBottomNavBarVisibilityChange(false)); + } else if (!isScrollingDown && !isBottomNavBarVisible) { + bloc.add(const OnBottomNavBarVisibilityChange(true)); } - _previousScrollPosition = currentScrollPosition; } - }); - - BackButtonInterceptor.add(_handleBack); + _previousScrollPosition = currentScrollPosition; + } } @override void dispose() { + _scrollController.removeListener(_onScroll); _scrollController.dispose(); BackButtonInterceptor.remove(_handleBack); super.dispose(); @@ -290,12 +297,14 @@ class _FeedViewState extends State { @override Widget build(BuildContext context) { - ThunderBloc thunderBloc = context.watch(); - final AppLocalizations l10n = AppLocalizations.of(context)!; + final l10n = GlobalContext.l10n; - bool tabletMode = thunderBloc.state.tabletMode; - bool markPostReadOnScroll = thunderBloc.state.markPostReadOnScroll; - bool hideTopBarOnScroll = thunderBloc.state.hideTopBarOnScroll; + final tabletMode = context.select((bloc) => bloc.state.tabletMode); + final markPostReadOnScroll = context.select((bloc) => bloc.state.markPostReadOnScroll); + final hideTopBarOnScroll = context.select((bloc) => bloc.state.hideTopBarOnScroll); + final isFabOpen = context.select((bloc) => bloc.state.isFabOpen); + final enableFeedsFab = context.select((bloc) => bloc.state.enableFeedsFab); + final showHiddenPosts = context.select((bloc) => bloc.state.showHiddenPosts); return Scaffold( body: SafeArea( @@ -305,7 +314,7 @@ class _FeedViewState extends State { if (previous.scrollId != current.scrollId) _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); if (previous.dismissReadId != current.dismissReadId) dismissRead(); if (current.dismissBlockedUserId != null || current.dismissBlockedCommunityId != null) dismissBlockedUsersAndCommunities(current.dismissBlockedUserId, current.dismissBlockedCommunityId); - if (current.dismissHiddenPostId != null && !thunderBloc.state.showHiddenPosts) dismissHiddenPost(current.dismissHiddenPostId!); + if (current.dismissHiddenPostId != null && !showHiddenPosts) dismissHiddenPost(current.dismissHiddenPostId!); if (current.excessiveApiCalls) { showSnackbar( l10n.excessiveApiCallsWarning, @@ -424,7 +433,7 @@ class _FeedViewState extends State { ), // Widget to host the feed FAB when navigating to new page AnimatedOpacity( - opacity: thunderBloc.state.isFabOpen ? 1.0 : 0.0, + opacity: isFabOpen ? 1.0 : 0.0, curve: Curves.easeInOut, duration: const Duration(milliseconds: 250), child: Stack( @@ -433,7 +442,7 @@ class _FeedViewState extends State { child: Container( color: theme.colorScheme.surface.withValues(alpha: 0.95), )), - if (thunderBloc.state.isFabOpen) + if (isFabOpen) ModalBarrier( color: null, dismissible: true, @@ -444,10 +453,10 @@ class _FeedViewState extends State { ), if (Navigator.of(context).canPop() && (state.communityId != null || state.communityName != null || state.userId != null || state.username != null) && - thunderBloc.state.enableFeedsFab && + enableFeedsFab && state.feedType != FeedType.account) AnimatedOpacity( - opacity: (thunderBloc.state.enableFeedsFab) ? 1.0 : 0.0, + opacity: enableFeedsFab ? 1.0 : 0.0, duration: const Duration(milliseconds: 150), curve: Curves.easeIn, child: Container( @@ -521,30 +530,42 @@ class FeedHeader extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final FeedBloc feedBloc = context.watch(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - getAppBarTitle(feedBloc.state), - style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4.0), - Row( + return BlocSelector( + selector: (state) => ( + feedListType: state.feedListType, + postSortType: state.postSortType, + feedType: state.feedType, + community: state.community, + user: state.user, + ), + builder: (context, _) { + final state = context.read().state; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon(getSortIcon(feedBloc.state), size: 17), - const SizedBox(width: 4), Text( - getSortName(feedBloc.state), - style: theme.textTheme.titleMedium, + getAppBarTitle(state), + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4.0), + Row( + children: [ + Icon(getSortIcon(state), size: 17), + const SizedBox(width: 4), + Text( + getSortName(state), + style: theme.textTheme.titleMedium, + ), + ], ), ], - ), - ], + ); + }, ); } } diff --git a/lib/src/features/feed/presentation/utils/post.dart b/lib/src/features/feed/presentation/utils/post.dart index 0f78d8dbc..eeb336ed8 100644 --- a/lib/src/features/feed/presentation/utils/post.dart +++ b/lib/src/features/feed/presentation/utils/post.dart @@ -41,8 +41,10 @@ Future> fetchFeedItems({ // Guarantee that we fetch at least x posts (unless we reach the end of the feed) if (communityId != null || communityName != null || feedListType != null) { + final postRepository = PostRepositoryImpl(account: account); + do { - Map response = await PostRepositoryImpl(account: account).getPosts( + Map response = await postRepository.getPosts( cursor: currentCursor, postSortType: postSortType, feedListType: feedListType, diff --git a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart index 2d82a877d..64285262a 100644 --- a/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart +++ b/lib/src/features/feed/presentation/widgets/feed_post_card_list.dart @@ -156,28 +156,35 @@ class _FeedPostCardListState extends State { ); } - return AnimatedSwitcher( - switchOutCurve: Curves.ease, - duration: Duration.zero, - reverseDuration: const Duration(milliseconds: 400), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: animation, curve: const Interval(0.5, 1.0)), - ), - child: SlideTransition( - position: Tween(begin: const Offset(1.2, 0.0), end: const Offset(0.0, 0.0)).animate(animation), - child: SizeTransition( - sizeFactor: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: animation, curve: const Interval(0.0, 0.25)), + // Only apply dismissal animation when the post is queued for removal + final isQueuedForRemoval = widget.queuedForRemoval?.contains(post.id) == true; + + if (isQueuedForRemoval) { + return AnimatedSwitcher( + switchOutCurve: Curves.ease, + duration: Duration.zero, + reverseDuration: const Duration(milliseconds: 400), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: const Interval(0.5, 1.0)), + ), + child: SlideTransition( + position: Tween(begin: const Offset(1.2, 0.0), end: const Offset(0.0, 0.0)).animate(animation), + child: SizeTransition( + sizeFactor: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: const Interval(0.0, 0.25)), + ), + child: child, ), - child: child, ), - ), - ); - }, - child: widget.queuedForRemoval?.contains(post.id) != true ? child : null, - ); + ); + }, + child: null, // Post is being removed, animate out + ); + } + + return child; } @override diff --git a/lib/src/features/post/data/models/thunder_post.dart b/lib/src/features/post/data/models/thunder_post.dart index 37bbe5322..4e132da62 100644 --- a/lib/src/features/post/data/models/thunder_post.dart +++ b/lib/src/features/post/data/models/thunder_post.dart @@ -1,9 +1,11 @@ +import 'package:equatable/equatable.dart'; + import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/subscription_status.dart'; import 'package:thunder/src/core/models/media.dart'; import 'package:thunder/src/features/user/user.dart'; -class ThunderPost { +class ThunderPost extends Equatable { /// The post's ID final int id; @@ -121,7 +123,7 @@ class ThunderPost { /// The media associated with the post final List media; - ThunderPost({ + const ThunderPost({ required this.id, required this.name, this.url, @@ -163,6 +165,49 @@ class ThunderPost { this.media = const [], }); + @override + List get props => [ + id, + name, + url, + body, + creatorId, + communityId, + removed, + locked, + published, + updated, + deleted, + nsfw, + thumbnailUrl, + apId, + local, + embedVideoUrl, + languageId, + featuredCommunity, + featuredLocal, + altText, + creator, + community, + imageDetails, + creatorBannedFromCommunity, + creatorIsModerator, + creatorIsAdmin, + comments, + score, + upvotes, + downvotes, + newestCommentTime, + subscribed, + saved, + read, + hidden, + creatorBlocked, + myVote, + unreadComments, + media, + ]; + ThunderPost copyWith({ int? id, String? name, diff --git a/lib/src/features/post/presentation/utils/post.dart b/lib/src/features/post/presentation/utils/post.dart index dcc103220..357aeedf9 100644 --- a/lib/src/features/post/presentation/utils/post.dart +++ b/lib/src/features/post/presentation/utils/post.dart @@ -114,9 +114,6 @@ Future> parsePosts(List posts, {String? resolutio /// /// This includes unescaping the title and parsing any associated media. Future parsePost(ThunderPost post, bool fetchImageDimensions, bool edgeToEdgeImages, bool tabletMode) async { - /// Whether to print debug logs - final bool debug = false; - final html = HtmlUnescape(); final title = html.convert(post.name); @@ -167,7 +164,7 @@ Future parsePost(ThunderPost post, bool fetchImageDimensions, bool if (useImageMetadata && post.imageDetails != null) { media.thumbnailUrl = post.imageDetails?['link'] ?? post.thumbnailUrl; - media.contentType = post.imageDetails?['contentType']; + media.contentType = post.imageDetails?['content_type']; size = Size(post.imageDetails?['width'].toDouble(), post.imageDetails?['height'].toDouble()); } else if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { // Now check to see if there is a thumbnail image. If there is, we'll use that for the image @@ -183,7 +180,7 @@ Future parsePost(ThunderPost post, bool fetchImageDimensions, bool int imageDimensionTimeout = UserPreferences.getLocalSetting(LocalSettings.imageDimensionTimeout) ?? 2; size = await retrieveImageDimensions(imageUrl: media.thumbnailUrl ?? media.mediaUrl).timeout(Duration(seconds: imageDimensionTimeout)); } catch (e) { - if (debug) debugPrint('${media.thumbnailUrl ?? media.originalUrl} - $e: Falling back to default image size'); + debugPrint('${media.thumbnailUrl ?? media.originalUrl} - $e: Falling back to default image size'); } } diff --git a/lib/src/features/user/data/models/thunder_user.dart b/lib/src/features/user/data/models/thunder_user.dart index 759c39ded..260e1b525 100644 --- a/lib/src/features/user/data/models/thunder_user.dart +++ b/lib/src/features/user/data/models/thunder_user.dart @@ -1,4 +1,6 @@ -class ThunderUser { +import 'package:equatable/equatable.dart'; + +class ThunderUser extends Equatable { /// The user's ID final int id; @@ -59,7 +61,7 @@ class ThunderUser { /// Whether the user is an admin. final bool? isAdmin; - ThunderUser({ + const ThunderUser({ required this.id, required this.name, this.displayName, @@ -81,6 +83,29 @@ class ThunderUser { this.isAdmin, }); + @override + List get props => [ + id, + name, + displayName, + avatar, + banned, + published, + updated, + actorId, + bio, + local, + banner, + deleted, + matrixUserId, + botAccount, + banExpires, + instanceId, + posts, + comments, + isAdmin, + ]; + ThunderUser copyWith({ int? id, String? name, diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header.dart b/lib/src/features/user/presentation/widgets/user_header/user_header.dart index 9bff6de87..10da1b1e4 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:auto_size_text/auto_size_text.dart'; +import 'package:thunder/src/shared/images/image_preview.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/font_scale.dart'; import 'package:thunder/src/features/feed/feed.dart'; @@ -224,13 +224,9 @@ class _BannerImage extends StatelessWidget { @override Widget build(BuildContext context) { return Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedNetworkImageProvider(url), - fit: BoxFit.cover, - ), - ), + child: ImagePreview( + url: url, + fit: BoxFit.cover, ), ); } diff --git a/lib/src/shared/images/image_preview.dart b/lib/src/shared/images/image_preview.dart index e10b9e0dd..f3dae2d7d 100644 --- a/lib/src/shared/images/image_preview.dart +++ b/lib/src/shared/images/image_preview.dart @@ -3,8 +3,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:flutter_avif/flutter_avif.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/media_type.dart'; import 'package:thunder/src/shared/utils/media/image.dart'; @@ -132,6 +133,7 @@ class _ImagePreviewState extends State { return _ImageContent( key: ValueKey(_currentUrl), url: _currentUrl, + contentType: widget.contentType, width: widget.width, height: widget.height, fit: widget.fit, @@ -151,6 +153,9 @@ class _ImageContent extends StatelessWidget { /// The URL of the image to display. final String url; + /// The content type of the image (e.g., 'image/avif', 'image/jpeg'). + final String? contentType; + /// The width of the image. final double? width; @@ -184,6 +189,7 @@ class _ImageContent extends StatelessWidget { const _ImageContent({ super.key, required this.url, + this.contentType, required this.width, required this.height, required this.fit, @@ -200,46 +206,104 @@ class _ImageContent extends StatelessWidget { Widget build(BuildContext context) { final devicePixelRatio = MediaQuery.devicePixelRatioOf(context).ceil(); - final image = CachedNetworkImage( - imageUrl: url, - height: height, - width: width, - fit: fit, - color: viewed == true ? const Color.fromRGBO(255, 255, 255, 0.55) : null, - colorBlendMode: viewed == true ? BlendMode.modulate : null, - fadeInDuration: const Duration(milliseconds: 130), - memCacheWidth: width != null ? (width! * devicePixelRatio).toInt() : null, - memCacheHeight: height != null ? (height! * devicePixelRatio).toInt() : null, - placeholder: (context, url) => const SizedBox.shrink(), - imageBuilder: (context, imageProvider) { - // Notify that the image loaded successfully - WidgetsBinding.instance.addPostFrameCallback((_) => onLoaded?.call()); - - return Image( - image: imageProvider, - height: height, - width: width, - fit: fit, - color: viewed == true ? const Color.fromRGBO(255, 255, 255, 0.55) : null, - colorBlendMode: viewed == true ? BlendMode.modulate : null, - ); - }, - errorWidget: (context, url, error) { - // Notify that the image failed to load - WidgetsBinding.instance.addPostFrameCallback((_) => onError?.call()); - - return ImagePreviewError( - mediaType: mediaType, - blur: blur == true, - viewed: viewed == true, - canRetry: canRetry, - onRetry: onRetry, - ); - }, - ); + // Calculate cache dimensions based on device pixel ratio + final int? cacheWidth = width != null ? (width! * devicePixelRatio).toInt() : null; + final int? cacheHeight = height != null ? (height! * devicePixelRatio).toInt() : null; + + final int? diskCacheWidth = cacheWidth != null ? (cacheWidth * 1.5).toInt() : null; + final int? diskCacheHeight = cacheHeight != null ? (cacheHeight * 1.5).toInt() : null; + + final filterQuality = (cacheWidth != null && cacheWidth < 200) ? FilterQuality.low : FilterQuality.medium; + + // Check if the URL is an AVIF image and use appropriate image loader + // + // Note: we need to check both URL and content type because: + // - Some servers (like lemmy.zip's image_proxy) serve AVIF images through URLs ending in .jpeg/.jpg/.png + // - The API provides content_type: 'image/avif' in imageDetails even when URL doesn't indicate AVIF + final isAvifByUrl = url.toLowerCase().endsWith('.avif'); + final isAvifByContentType = contentType?.toLowerCase() == 'image/avif'; + final isAvif = isAvifByUrl || isAvifByContentType; + + Widget image; + + if (isAvif) { + image = CachedNetworkAvifImage( + url, + height: height, + width: width, + fit: fit ?? BoxFit.cover, + color: viewed == true ? const Color.fromRGBO(255, 255, 255, 0.55) : null, + colorBlendMode: viewed == true ? BlendMode.modulate : null, + filterQuality: filterQuality, + isAntiAlias: false, + gaplessPlayback: false, + errorBuilder: (context, error, stackTrace) { + Future.microtask(() => onError?.call()); + return ImagePreviewError( + mediaType: mediaType, + blur: blur == true, + viewed: viewed == true, + canRetry: canRetry, + onRetry: onRetry, + ); + }, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (frame != null) { + // Image has loaded - notify callback + Future.microtask(() => onLoaded?.call()); + } + return child; + }, + ); + } else { + image = CachedNetworkImage( + imageUrl: url, + height: height, + width: width, + fit: fit, + color: viewed == true ? const Color.fromRGBO(255, 255, 255, 0.55) : null, + colorBlendMode: viewed == true ? BlendMode.modulate : null, + fadeInDuration: const Duration(milliseconds: 100), + fadeOutDuration: Duration.zero, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, + maxWidthDiskCache: diskCacheWidth, + maxHeightDiskCache: diskCacheHeight, + filterQuality: filterQuality, + useOldImageOnUrlChange: true, + placeholder: (context, url) => const SizedBox.shrink(), + imageBuilder: (context, imageProvider) { + Future.microtask(() => onLoaded?.call()); + + return Image( + image: imageProvider, + height: height, + width: width, + fit: fit, + color: viewed == true ? const Color.fromRGBO(255, 255, 255, 0.55) : null, + colorBlendMode: viewed == true ? BlendMode.modulate : null, + filterQuality: filterQuality, + gaplessPlayback: false, + isAntiAlias: false, + ); + }, + errorWidget: (context, url, error) { + Future.microtask(() => onError?.call()); + + return ImagePreviewError( + mediaType: mediaType, + blur: blur == true, + viewed: viewed == true, + canRetry: canRetry, + onRetry: onRetry, + ); + }, + ); + } if (blur == true) return _BlurredImage(child: image); - return image; + + return RepaintBoundary(child: image); } } diff --git a/lib/src/shared/utils/media/image.dart b/lib/src/shared/utils/media/image.dart index 0fa6f55ef..f2931a7fa 100644 --- a/lib/src/shared/utils/media/image.dart +++ b/lib/src/shared/utils/media/image.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:ui'; @@ -5,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart' as http; import 'package:image/image.dart' as img; @@ -113,7 +115,7 @@ bool _isAvifImage(String path) { } /// Retrieves the size of the given image given its bytes. -/// Uses the `image` package which does not support AVIF format. For AVIF images, use [processImageWithFlutter] instead. +/// Uses the `image` package which does not support AVIF format. For AVIF images, use [processAvifImage] instead. Future processImage(String filename) async { final bytes = await File(filename).readAsBytes(); final image = img.decodeImage(bytes); @@ -122,20 +124,27 @@ Future processImage(String filename) async { return Size(image.width.toDouble(), image.height.toDouble()); } -/// Retrieves the size of the given image using Flutter's native image codec. -/// This supports AVIF format (on iOS 16+ and Android 10+) but must run on the main isolate. -Future processImageWithFlutter(String filename) async { +/// Retrieves the size of an AVIF image using flutter_avif +Future processAvifImage(String filename) async { final bytes = await File(filename).readAsBytes(); - final buffer = await ImmutableBuffer.fromUint8List(bytes); - final descriptor = await ImageDescriptor.encoded(buffer); + final frames = await decodeAvif(bytes); - final size = Size(descriptor.width.toDouble(), descriptor.height.toDouble()); - descriptor.dispose(); + if (frames.isEmpty) throw Exception('Failed to decode AVIF image'); + + final firstFrame = frames.first; + final size = Size(firstFrame.image.width.toDouble(), firstFrame.image.height.toDouble()); + + // Dispose the decoded images to free memory + for (final frame in frames) { + frame.image.dispose(); + } return size; } /// Retrieves the size of the given image. Must provide either [imageUrl] or [imageBytes]. +/// +/// For AVIF images, uses flutter_avif to determine dimensions. Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) async { assert(imageUrl != null || imageBytes != null); @@ -152,11 +161,10 @@ Future retrieveImageDimensions({String? imageUrl, Uint8List? imageBytes}) if (data == null && imageUrl != null) { final file = await DefaultCacheManager().getSingleFile(imageUrl); - // AVIF images require Flutter's native codec which must run on the main isolate. - // Other formats can be processed in a background isolate using the `image` package. if (_isAvifImage(imageUrl) || _isAvifImage(file.path)) { - size = await processImageWithFlutter(file.path); + size = await processAvifImage(file.path); } else { + // Other formats can be processed in a background isolate using the `image` package. size = await compute(processImage, file.path); } } diff --git a/lib/src/shared/widgets/media/compact_thumbnail_preview.dart b/lib/src/shared/widgets/media/compact_thumbnail_preview.dart index 50f33505e..5ca75893e 100644 --- a/lib/src/shared/widgets/media/compact_thumbnail_preview.dart +++ b/lib/src/shared/widgets/media/compact_thumbnail_preview.dart @@ -40,29 +40,31 @@ class CompactThumbnailPreview extends StatelessWidget { final isUserLoggedIn = context.select((ProfileBloc bloc) => bloc.state.isLoggedIn); - return ExcludeSemantics( - child: Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), - child: MediaView( - media: media, - postId: postId, - showFullHeightImages: false, - hideNsfwPreviews: hideNsfwPreviews, - markPostReadOnMediaView: markPostReadOnMediaView, - viewMode: ViewMode.compact, - isUserLoggedIn: isUserLoggedIn, - navigateToPost: navigateToPost, - read: dim, + return RepaintBoundary( + child: ExcludeSemantics( + child: Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), + child: MediaView( + media: media, + postId: postId, + showFullHeightImages: false, + hideNsfwPreviews: hideNsfwPreviews, + markPostReadOnMediaView: markPostReadOnMediaView, + viewMode: ViewMode.compact, + isUserLoggedIn: isUserLoggedIn, + navigateToPost: navigateToPost, + read: dim, + ), ), - ), - Padding( - padding: const EdgeInsets.only(right: 6.0), - child: MediaTypeBadge(mediaType: media.mediaType, dim: dim), - ), - ], + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: MediaTypeBadge(mediaType: media.mediaType, dim: dim), + ), + ], + ), ), ); } diff --git a/lib/src/shared/widgets/media/media_view.dart b/lib/src/shared/widgets/media/media_view.dart index 624e49858..6ec527c37 100644 --- a/lib/src/shared/widgets/media/media_view.dart +++ b/lib/src/shared/widgets/media/media_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/models/media.dart'; import 'package:thunder/src/shared/images/image_preview.dart'; import 'package:thunder/src/shared/link_information.dart'; @@ -196,11 +197,11 @@ class _MediaViewState extends State with TickerProviderStateMixin { // At this point, all other media types should contain images, so we display the image as well as any additional information final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - final state = context.read().state; final imagePeekDurationMs = context.select((ThunderBloc bloc) => bloc.state.imagePeekDuration); + final tabletMode = widget.viewMode == ViewMode.comfortable ? context.select((ThunderBloc bloc) => bloc.state.tabletMode) : false; final blurNSFWPreviews = widget.hideNsfwPreviews && widget.media.nsfw; + late final l10n = GlobalContext.l10n; double? width; double? height; @@ -215,7 +216,7 @@ class _MediaViewState extends State with TickerProviderStateMixin { height = ViewMode.compact.height; break; case ViewMode.comfortable: - width = (state.tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24); + width = (tabletMode ? (MediaQuery.of(context).size.width / 2) - 24.0 : MediaQuery.of(context).size.width) - (widget.edgeToEdgeImages ? 0 : 24); height = (widget.showFullHeightImages && !widget.allowUnconstrainedImageHeight) ? widget.media.height : null; } diff --git a/pubspec.lock b/pubspec.lock index 6411d7ded..cbf4c7036 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -385,6 +385,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + exif: + dependency: transitive + description: + name: exif + sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172 + url: "https://pub.dev" + source: hosted + version: "3.3.0" expandable: dependency: "direct main" description: @@ -502,6 +510,70 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_avif: + dependency: "direct main" + description: + name: flutter_avif + sha256: "6035f073189c1ae134affa73338c2eca79bf412e9abdb8f8628fdefcccff4444" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_android: + dependency: transitive + description: + name: flutter_avif_android + sha256: "1da135ea0d74225fae3154f954f9ba4cf7aa5e56b30d9851fcfc0ddecab9d8c5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_ios: + dependency: transitive + description: + name: flutter_avif_ios + sha256: "3daaa599d8fe0193d3ade6cafa1fd4f166d4063b8d499c7c311ce1d170b2759f" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_linux: + dependency: transitive + description: + name: flutter_avif_linux + sha256: dd6179edca12c720761b3acc678d7f15208eb8647adbac5a6a90f52d4ba56e2d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_macos: + dependency: transitive + description: + name: flutter_avif_macos + sha256: "8e39f365dfc5713d4527f07a92399184857ea30eced85f82171bfd0a45e2c73b" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_platform_interface: + dependency: transitive + description: + name: flutter_avif_platform_interface + sha256: f5c110bdb7de2d4ab4902bfe2ec331fe24bc19b6d54f00ac4fa165effc0edcda + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_web: + dependency: transitive + description: + name: flutter_avif_web + sha256: db2ff395b08cbfe9116e48ac65c428ba21a9159972ea019f8d7be580ff6cf6e8 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_avif_windows: + dependency: transitive + description: + name: flutter_avif_windows + sha256: e0c7722df305a6621f4ecdd83098025c705bcf0e8ce9519e03c6f515abcf351d + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_bloc: dependency: "direct main" description: @@ -1343,6 +1415,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" provider: dependency: transitive description: @@ -1548,6 +1628,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2f5a04674..7a87bd69c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: freezed: ^3.2.2 image: ^4.5.4 sqlite3: ^2.9.4 + flutter_avif: ^3.1.0 dev_dependencies: flutter_test: