[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

double tap to like (#59)

ta ruim mas ta bom

authored by

Davi Rodrigues and committed by
GitHub
43d57936 17f6210b

+289 -90
+94
lib/src/core/widgets/heart_animation.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class HeartAnimation extends StatefulWidget { 4 + final bool isAnimating; 5 + final Duration duration; 6 + final VoidCallback? onEnd; 7 + final Widget child; 8 + 9 + const HeartAnimation({ 10 + super.key, 11 + required this.isAnimating, 12 + this.duration = const Duration(milliseconds: 1000), 13 + this.onEnd, 14 + required this.child, 15 + }); 16 + 17 + @override 18 + State<HeartAnimation> createState() => _HeartAnimationState(); 19 + } 20 + 21 + class _HeartAnimationState extends State<HeartAnimation> with TickerProviderStateMixin { 22 + late AnimationController _controller; 23 + late Animation<double> _scaleAnimation; 24 + late Animation<double> _opacityAnimation; 25 + 26 + @override 27 + void initState() { 28 + super.initState(); 29 + _controller = AnimationController(duration: widget.duration, vsync: this); 30 + 31 + _scaleAnimation = Tween<double>(begin: 0.0, end: 1.4).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); 32 + 33 + _opacityAnimation = Tween<double>(begin: 0.8, end: 0.0).animate( 34 + CurvedAnimation( 35 + parent: _controller, 36 + curve: const Interval(0.5, 1.0, curve: Curves.easeOut), 37 + ), 38 + ); 39 + 40 + _controller.addStatusListener((status) { 41 + if (status == AnimationStatus.completed) { 42 + widget.onEnd?.call(); 43 + } 44 + }); 45 + } 46 + 47 + @override 48 + void didUpdateWidget(HeartAnimation oldWidget) { 49 + super.didUpdateWidget(oldWidget); 50 + if (widget.isAnimating != oldWidget.isAnimating) { 51 + if (widget.isAnimating) { 52 + _controller.reset(); 53 + _controller.forward(); 54 + } 55 + } 56 + } 57 + 58 + @override 59 + void dispose() { 60 + _controller.dispose(); 61 + super.dispose(); 62 + } 63 + 64 + @override 65 + Widget build(BuildContext context) { 66 + return Stack( 67 + children: [ 68 + widget.child, 69 + if (widget.isAnimating) 70 + Positioned.fill( 71 + child: Center( 72 + child: AnimatedBuilder( 73 + animation: _controller, 74 + builder: (context, child) { 75 + return Transform.scale( 76 + scale: _scaleAnimation.value, 77 + child: Opacity( 78 + opacity: _opacityAnimation.value, 79 + child: const Icon( 80 + Icons.favorite, 81 + color: Colors.red, 82 + size: 100, 83 + shadows: [Shadow(offset: Offset(0, 0), blurRadius: 10, color: Colors.red)], 84 + ), 85 + ), 86 + ); 87 + }, 88 + ), 89 + ), 90 + ), 91 + ], 92 + ); 93 + } 94 + }
+12 -2
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 42 42 }); 43 43 44 44 @override 45 - ConsumerState<SideActionBar> createState() => _VideoSideActionBarState(); 45 + ConsumerState<SideActionBar> createState() => SideActionBarState(); 46 46 } 47 47 48 - class _VideoSideActionBarState extends ConsumerState<SideActionBar> { 48 + class SideActionBarState extends ConsumerState<SideActionBar> { 49 49 bool _isLiked = false; 50 50 PostView? _currentPost; // Track the current post state locally 51 51 ··· 67 67 if (oldWidget.post != widget.post) { 68 68 setState(() { 69 69 _currentPost = widget.post; 70 + }); 71 + } 72 + } 73 + 74 + /// Public method to update like state from external double-tap 75 + void updateLikeState(PostView updatedPost) { 76 + if (mounted) { 77 + setState(() { 78 + _isLiked = updatedPost.viewer?.like != null; 79 + _currentPost = updatedPost; 70 80 }); 71 81 } 72 82 }
+85 -41
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 7 7 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 8 8 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 9 9 import 'package:sparksocial/src/features/feed/providers/post_updates.dart'; 10 + import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 10 11 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 11 12 import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 12 13 import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 13 14 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 14 15 import 'package:sparksocial/src/features/home/providers/navigation_provider.dart'; 15 16 import 'package:sparksocial/src/core/routing/app_router.dart'; 17 + import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 16 18 17 19 class FeedPostWidget extends ConsumerStatefulWidget { 18 20 const FeedPostWidget({super.key, required this.index, required this.feed}); ··· 29 31 String? _lastPostUri; 30 32 int? _lastUpdateCount; 31 33 final GlobalKey<PostVideoPlayerState> _videoPlayerKey = GlobalKey<PostVideoPlayerState>(); 34 + final GlobalKey<SideActionBarState> _sideActionBarKey = GlobalKey<SideActionBarState>(); 35 + bool _isAnimatingHeart = false; 32 36 33 37 @override 34 38 void initState() { ··· 57 61 } 58 62 } 59 63 64 + Future<void> _handleDoubleTapLike(PostView postData) async { 65 + final isCurrentlyLiked = postData.viewer?.like != null; 66 + 67 + if (isCurrentlyLiked) { 68 + return; 69 + } 70 + 71 + // Start heart animation 72 + setState(() { 73 + _isAnimatingHeart = true; 74 + }); 75 + 76 + try { 77 + // Like the post using the same logic as SideActionBar 78 + final newLike = await ref.read(likePostProvider(postData.cid, postData.uri).future); 79 + 80 + // Update the post's viewer field with the new like reference 81 + final updatedPost = postData.copyWith( 82 + viewer: postData.viewer?.copyWith(like: newLike.uri) ?? Viewer(like: newLike.uri, repost: postData.viewer?.repost), 83 + ); 84 + 85 + // Update cache with the modified post 86 + await GetIt.instance<SQLCacheInterface>().updatePost(updatedPost); 87 + 88 + // Update SideActionBar state directly 89 + _sideActionBarKey.currentState?.updateLikeState(updatedPost); 90 + } catch (e) { 91 + // Handle error silently for better UX 92 + } 93 + } 94 + 60 95 @override 61 96 Widget build(BuildContext context) { 62 97 // Check if we need to reload post due to state changes ··· 100 135 if (snapshot.connectionState == ConnectionState.done && snapshot.hasData) { 101 136 final postData = snapshot.data! as PostView; 102 137 final sideActionBar = SideActionBar( 138 + key: _sideActionBarKey, 103 139 post: postData, 104 140 likeCount: '${postData.likeCount ?? 0}', 105 141 commentCount: '${postData.replyCount ?? 0}', ··· 113 149 }, 114 150 ); 115 151 116 - return GestureDetector( 117 - onDoubleTap: () {}, 118 - child: Stack( 119 - children: [ 120 - // Main content 121 - switch (postData.embed) { 122 - EmbedViewVideo() => PostVideoPlayer( 123 - key: _videoPlayerKey, 124 - videoUrl: postData.videoUrl, 125 - feed: widget.feed, 126 - index: widget.index, 127 - isSparkPost: true, 128 - ), 129 - EmbedViewBskyVideo() => PostVideoPlayer( 130 - key: _videoPlayerKey, 131 - videoUrl: postData.videoUrl, 132 - feed: widget.feed, 133 - index: widget.index, 134 - isSparkPost: false, 135 - ), 136 - EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 137 - EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 152 + return HeartAnimation( 153 + isAnimating: _isAnimatingHeart, 154 + onEnd: () { 155 + setState(() { 156 + _isAnimatingHeart = false; 157 + }); 158 + }, 159 + child: GestureDetector( 160 + onDoubleTap: () => _handleDoubleTapLike(postData), 161 + child: Stack( 162 + children: [ 163 + // Main content 164 + switch (postData.embed) { 138 165 EmbedViewVideo() => PostVideoPlayer( 139 166 key: _videoPlayerKey, 140 167 videoUrl: postData.videoUrl, ··· 150 177 isSparkPost: false, 151 178 ), 152 179 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 180 + EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 181 + EmbedViewVideo() => PostVideoPlayer( 182 + key: _videoPlayerKey, 183 + videoUrl: postData.videoUrl, 184 + feed: widget.feed, 185 + index: widget.index, 186 + isSparkPost: true, 187 + ), 188 + EmbedViewBskyVideo() => PostVideoPlayer( 189 + key: _videoPlayerKey, 190 + videoUrl: postData.videoUrl, 191 + feed: widget.feed, 192 + index: widget.index, 193 + isSparkPost: false, 194 + ), 195 + EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 196 + _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 197 + }, 153 198 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 154 199 }, 155 - _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 156 - }, 157 200 158 - // Side action bar 159 - Positioned(bottom: 4, right: 4, child: sideActionBar), 201 + // Side action bar 202 + Positioned(bottom: 4, right: 4, child: sideActionBar), 160 203 161 - Positioned( 162 - bottom: 32, 163 - left: 4, 164 - right: 80, 165 - child: InfoBar( 166 - username: postData.author.handle, 167 - description: postData.record.text ?? '', 168 - hashtags: postData.record.hashtags, 169 - isSprk: postData.uri.toString().contains('so.sprk'), 170 - onUsernameTap: () { 171 - _videoPlayerKey.currentState?.pauseVideo(); 172 - context.router.push(ProfileRoute(did: postData.author.did)); 173 - }, 204 + Positioned( 205 + bottom: 32, 206 + left: 4, 207 + right: 80, 208 + child: InfoBar( 209 + username: postData.author.handle, 210 + description: postData.record.text ?? '', 211 + hashtags: postData.record.hashtags, 212 + isSprk: postData.uri.toString().contains('so.sprk'), 213 + onUsernameTap: () { 214 + _videoPlayerKey.currentState?.pauseVideo(); 215 + context.router.push(ProfileRoute(did: postData.author.did)); 216 + }, 217 + ), 174 218 ), 175 - ), 176 - ], 219 + ], 220 + ), 177 221 ), 178 222 ); 179 223 }
+98 -47
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 8 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 9 import 'package:sparksocial/src/core/storage/cache/sql_cache_interface.dart'; 10 10 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 11 + import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 11 12 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 12 13 import 'package:sparksocial/src/features/feed/ui/widgets/images/image_carousel.dart'; 13 14 import 'package:sparksocial/src/features/feed/ui/widgets/post/info_bar.dart'; 14 15 import 'package:sparksocial/src/features/feed/ui/widgets/videos/video_player.dart'; 16 + import 'package:sparksocial/src/core/widgets/heart_animation.dart'; 15 17 16 - class ProfileFeedPostWidget extends ConsumerWidget { 18 + class ProfileFeedPostWidget extends ConsumerStatefulWidget { 17 19 final AtUri postUri; 18 20 final AtUri profileUri; 19 21 final bool videosOnly; 20 22 21 23 const ProfileFeedPostWidget({super.key, required this.postUri, required this.profileUri, required this.videosOnly}); 22 24 25 + @override 26 + ConsumerState<ProfileFeedPostWidget> createState() => _ProfileFeedPostWidgetState(); 27 + } 28 + 29 + class _ProfileFeedPostWidgetState extends ConsumerState<ProfileFeedPostWidget> { 30 + bool _isAnimatingHeart = false; 31 + final GlobalKey<SideActionBarState> _sideActionBarKey = GlobalKey<SideActionBarState>(); 32 + 23 33 Future<PostView?> _loadPostWithFallback() async { 24 34 final sqlCache = GetIt.instance<SQLCacheInterface>(); 25 35 26 36 try { 27 37 // Try to get from cache first 28 - final cachedPost = await sqlCache.getPost(postUri.toString()); 38 + final cachedPost = await sqlCache.getPost(widget.postUri.toString()); 29 39 return cachedPost; 30 40 } catch (e) { 31 41 // Cache lookup failed, continue to network fetch ··· 37 47 List<PostView> networkPost; 38 48 try { 39 49 // Try Spark network first 40 - networkPost = await feedRepository.getPosts([postUri], bluesky: false); 50 + networkPost = await feedRepository.getPosts([widget.postUri], bluesky: false); 41 51 } catch (e) { 42 52 // Fallback to Bluesky network 43 - networkPost = await feedRepository.getPosts([postUri], bluesky: true); 53 + networkPost = await feedRepository.getPosts([widget.postUri], bluesky: true); 44 54 } 45 55 46 56 if (networkPost.isEmpty) { ··· 52 62 53 63 return networkPost.first; 54 64 } 65 + 66 + Future<void> _handleDoubleTapLike(PostView postData) async { 67 + final isCurrentlyLiked = postData.viewer?.like != null; 68 + 69 + if (isCurrentlyLiked) { 70 + return; 71 + } 72 + 73 + // Start heart animation 74 + setState(() { 75 + _isAnimatingHeart = true; 76 + }); 77 + 78 + try { 79 + // Like the post using the same logic as SideActionBar 80 + final newLike = await ref.read(likePostProvider(postData.cid, postData.uri).future); 81 + 82 + // Update the post's viewer field with the new like reference 83 + final updatedPost = postData.copyWith( 84 + viewer: postData.viewer?.copyWith(like: newLike.uri) ?? Viewer(like: newLike.uri, repost: postData.viewer?.repost), 85 + ); 86 + 87 + // Update cache with the modified post 88 + await GetIt.instance<SQLCacheInterface>().updatePost(updatedPost); 89 + 90 + // Update SideActionBar state directly 91 + _sideActionBarKey.currentState?.updateLikeState(updatedPost); 92 + } catch (e) { 93 + // Handle error silently for better UX 94 + } 95 + } 96 + 55 97 @override 56 - Widget build(BuildContext context, WidgetRef ref) { 98 + Widget build(BuildContext context) { 57 99 return SafeArea( 58 100 child: FutureBuilder<PostView?>( 59 101 future: _loadPostWithFallback(), ··· 84 126 final post = snapshot.data!; 85 127 86 128 // Create a simple post display similar to FeedPostWidget but without feed dependencies 87 - return GestureDetector( 88 - onDoubleTap: () {}, 89 - child: Stack( 90 - children: [ 91 - // Main content 92 - switch (post.embed) { 93 - EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: true), 94 - EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: false), 95 - EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 96 - EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 129 + return HeartAnimation( 130 + isAnimating: _isAnimatingHeart, 131 + onEnd: () { 132 + setState(() { 133 + _isAnimatingHeart = false; 134 + }); 135 + }, 136 + child: GestureDetector( 137 + onDoubleTap: () => _handleDoubleTapLike(post), 138 + child: Stack( 139 + children: [ 140 + // Main content 141 + switch (post.embed) { 97 142 EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: true), 98 143 EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: false), 99 144 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 145 + EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 146 + EmbedViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: true), 147 + EmbedViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, isSparkPost: false), 148 + EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 149 + _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 150 + }, 100 151 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 101 152 }, 102 - _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 103 - }, 104 153 105 - // Side action bar 106 - Positioned( 107 - bottom: 4, 108 - right: 4, 109 - child: SideActionBar( 110 - post: post, 111 - likeCount: '${post.likeCount ?? 0}', 112 - commentCount: '${post.replyCount ?? 0}', 113 - shareCount: '${post.repostCount ?? 0}', 114 - isLiked: post.viewer?.like != null, 115 - profileImageUrl: post.author.avatar.toString(), 116 - isImage: post.embed is EmbedViewImage || post.embed is EmbedViewBskyImages, 117 - onProfilePressed: () { 118 - // No special handling needed for profile navigation in standalone feed 119 - }, 154 + // Side action bar 155 + Positioned( 156 + bottom: 4, 157 + right: 4, 158 + child: SideActionBar( 159 + key: _sideActionBarKey, 160 + post: post, 161 + likeCount: '${post.likeCount ?? 0}', 162 + commentCount: '${post.replyCount ?? 0}', 163 + shareCount: '${post.repostCount ?? 0}', 164 + isLiked: post.viewer?.like != null, 165 + profileImageUrl: post.author.avatar.toString(), 166 + isImage: post.embed is EmbedViewImage || post.embed is EmbedViewBskyImages, 167 + onProfilePressed: () { 168 + // No special handling needed for profile navigation in standalone feed 169 + }, 170 + ), 120 171 ), 121 - ), 122 172 123 - Positioned( 124 - bottom: 32, 125 - left: 4, 126 - right: 80, 127 - child: InfoBar( 128 - username: post.author.handle, 129 - description: post.record.text ?? '', 130 - hashtags: post.record.hashtags, 131 - isSprk: post.uri.toString().contains('so.sprk'), 132 - onUsernameTap: () { 133 - context.router.push(ProfileRoute(did: post.author.did)); 134 - }, 173 + Positioned( 174 + bottom: 32, 175 + left: 4, 176 + right: 80, 177 + child: InfoBar( 178 + username: post.author.handle, 179 + description: post.record.text ?? '', 180 + hashtags: post.record.hashtags, 181 + isSprk: post.uri.toString().contains('so.sprk'), 182 + onUsernameTap: () { 183 + context.router.push(ProfileRoute(did: post.author.did)); 184 + }, 185 + ), 135 186 ), 136 - ), 137 - ], 187 + ], 188 + ), 138 189 ), 139 190 ); 140 191 },