[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.

fix(profile): restore feed view

+224 -143
+147 -57
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 1 + import 'dart:ui'; 2 + 1 3 import 'package:atproto_core/atproto_core.dart'; 2 4 import 'package:auto_route/auto_route.dart'; 3 5 import 'package:flutter/material.dart'; 4 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 + import 'package:sparksocial/src/core/design_system/tokens/constants.dart'; 8 + import 'package:sparksocial/src/core/routing/app_router.dart'; 5 9 import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 6 10 import 'package:sparksocial/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 7 11 import 'package:sparksocial/src/features/feed/ui/widgets/feed/snappy_page_scroll_physics.dart'; ··· 27 31 class _StandaloneProfileFeedPageState extends ConsumerState<StandaloneProfileFeedPage> { 28 32 late final PageController pageController; 29 33 late final AtUri profileAtUri; 34 + int _currentIndex = 0; 30 35 31 36 @override 32 37 void initState() { 33 38 super.initState(); 34 39 profileAtUri = AtUri.parse(widget.profileUri); 40 + _currentIndex = widget.initialPostIndex; 35 41 pageController = PageController(initialPage: widget.initialPostIndex); 36 42 } 37 43 ··· 44 50 @override 45 51 Widget build(BuildContext context) { 46 52 final feedState = ref.watch(profileFeedProvider(profileAtUri, widget.videosOnly)); 53 + final bottomPadding = MediaQuery.of(context).padding.bottom; 47 54 48 55 return Scaffold( 49 56 backgroundColor: AppColors.black, 50 - appBar: AppBar(backgroundColor: AppColors.black, leading: const AutoLeadingButton()), 51 - body: feedState.when( 52 - data: (state) { 53 - // Filter client-side 54 - final filteredUris = widget.videosOnly 55 - ? state.loadedPosts.where((u) => state.postTypes[u] ?? true).toList() 56 - : state.loadedPosts.where((u) => state.postTypes[u] == false).toList(); 57 + body: Stack( 58 + children: [ 59 + // Full-screen content 60 + feedState.when( 61 + data: (state) { 62 + // Display all posts returned by server - no client-side filtering 63 + final filteredUris = state.loadedPosts; 57 64 58 - if (filteredUris.isEmpty) { 59 - return const Center( 60 - child: Text('No posts available', style: TextStyle(color: AppColors.white)), 61 - ); 62 - } 63 - 64 - // Ensure initial index is within bounds 65 - final safeInitialIndex = widget.initialPostIndex.clamp(0, filteredUris.length - 1); 66 - 67 - // Update page controller if needed 68 - if (pageController.hasClients && pageController.page?.round() != safeInitialIndex) { 69 - WidgetsBinding.instance.addPostFrameCallback((_) { 70 - if (mounted && pageController.hasClients) { 71 - pageController.jumpToPage(safeInitialIndex); 65 + if (filteredUris.isEmpty) { 66 + return const Center( 67 + child: Text('No posts available', style: TextStyle(color: AppColors.white)), 68 + ); 72 69 } 73 - }); 74 - } 75 70 76 - return CacheablePageView.builder( 77 - controller: pageController, 78 - scrollDirection: Axis.vertical, 79 - physics: const SnappyPageScrollPhysics(), 80 - itemCount: filteredUris.length, 81 - onPageChanged: (index) { 82 - // Load more posts when approaching the end 83 - if (index >= filteredUris.length - 3 && !state.isEndOfNetwork) { 84 - ref.read(profileFeedProvider(profileAtUri, widget.videosOnly).notifier).loadMore(); 85 - } 71 + return CacheablePageView.builder( 72 + cachePageExtent: 1, 73 + controller: pageController, 74 + scrollDirection: Axis.vertical, 75 + physics: const SnappyPageScrollPhysics(), 76 + allowImplicitScrolling: true, 77 + itemCount: filteredUris.length, 78 + onPageChanged: (index) { 79 + setState(() { 80 + _currentIndex = index; 81 + }); 82 + // Load more posts when approaching the end 83 + if (index >= filteredUris.length - 3 && !state.isEndOfNetwork) { 84 + ref.read(profileFeedProvider(profileAtUri, widget.videosOnly).notifier).loadMore(); 85 + } 86 + }, 87 + itemBuilder: (context, index) { 88 + final postUri = filteredUris[index]; 89 + final post = state.postViews[postUri]; 90 + return ProfileFeedPostWidget( 91 + postUri: postUri, 92 + profileUri: profileAtUri, 93 + videosOnly: widget.videosOnly, 94 + post: post, 95 + ); 96 + }, 97 + ); 86 98 }, 87 - itemBuilder: (context, index) { 88 - final postUri = filteredUris[index]; 89 - final post = state.postViews[postUri]; 90 - return ProfileFeedPostWidget(postUri: postUri, profileUri: profileAtUri, videosOnly: widget.videosOnly, post: post); 91 - }, 92 - ); 99 + loading: () => const Center(child: CircularProgressIndicator(color: AppColors.white)), 100 + error: (error, stack) => Center( 101 + child: Column( 102 + mainAxisAlignment: MainAxisAlignment.center, 103 + children: [ 104 + const Icon(Icons.error_outline, color: AppColors.white, size: 48), 105 + const SizedBox(height: 16), 106 + Text( 107 + 'Error loading feed: $error', 108 + style: const TextStyle(color: AppColors.white), 109 + textAlign: TextAlign.center, 110 + ), 111 + const SizedBox(height: 16), 112 + ElevatedButton( 113 + onPressed: () { 114 + ref.read(profileFeedProvider(profileAtUri, widget.videosOnly).notifier).refresh(); 115 + }, 116 + child: const Text('Retry'), 117 + ), 118 + ], 119 + ), 120 + ), 121 + ), 122 + // Back button overlay - respects safe area for the button only 123 + Positioned( 124 + top: 0, 125 + left: 0, 126 + child: SafeArea( 127 + child: Padding( 128 + padding: const EdgeInsets.all(8), 129 + child: IconButton( 130 + icon: const Icon(Icons.arrow_back, color: AppColors.white), 131 + onPressed: () => context.router.maybePop(), 132 + ), 133 + ), 134 + ), 135 + ), 136 + ], 137 + ), 138 + bottomNavigationBar: _CommentBar( 139 + bottomPadding: bottomPadding, 140 + onTap: () { 141 + final state = feedState.value; 142 + if (state != null && state.loadedPosts.isNotEmpty) { 143 + final currentPostUri = state.loadedPosts[_currentIndex]; 144 + final post = state.postViews[currentPostUri]; 145 + if (post != null) { 146 + context.router.push(CommentsRoute(postUri: post.uri.toString(), isSprk: post.isSprk, post: post)); 147 + } 148 + } 93 149 }, 94 - loading: () => const Center(child: CircularProgressIndicator(color: AppColors.white)), 95 - error: (error, stack) => Center( 96 - child: Column( 97 - mainAxisAlignment: MainAxisAlignment.center, 98 - children: [ 99 - const Icon(Icons.error_outline, color: AppColors.white, size: 48), 100 - const SizedBox(height: 16), 101 - Text( 102 - 'Error loading feed: $error', 103 - style: const TextStyle(color: AppColors.white), 104 - textAlign: TextAlign.center, 150 + ), 151 + ); 152 + } 153 + } 154 + 155 + class _CommentBar extends StatelessWidget { 156 + const _CommentBar({ 157 + required this.bottomPadding, 158 + required this.onTap, 159 + }); 160 + 161 + final double bottomPadding; 162 + final VoidCallback onTap; 163 + 164 + @override 165 + Widget build(BuildContext context) { 166 + return ClipRRect( 167 + child: BackdropFilter( 168 + filter: ImageFilter.blur( 169 + sigmaX: AppConstants.blurBottomBar.toDouble(), 170 + sigmaY: AppConstants.blurBottomBar.toDouble(), 171 + ), 172 + child: DecoratedBox( 173 + decoration: BoxDecoration( 174 + color: const Color.fromARGB(51, 0, 0, 0), 175 + border: Border( 176 + top: BorderSide(color: Colors.white.withValues(alpha: 0.08), width: 2), 177 + ), 178 + ), 179 + child: GestureDetector( 180 + behavior: HitTestBehavior.opaque, 181 + onTap: onTap, 182 + child: Container( 183 + padding: EdgeInsets.only( 184 + left: 16, 185 + right: 16, 186 + top: 12, 187 + bottom: 12 + bottomPadding, 105 188 ), 106 - const SizedBox(height: 16), 107 - ElevatedButton( 108 - onPressed: () { 109 - ref.read(profileFeedProvider(profileAtUri, widget.videosOnly).notifier).refresh(); 110 - }, 111 - child: const Text('Retry'), 189 + child: Container( 190 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 191 + decoration: BoxDecoration( 192 + color: Colors.white.withValues(alpha: 0.1), 193 + borderRadius: BorderRadius.circular(24), 194 + ), 195 + child: const Text( 196 + 'Add comment...', 197 + style: TextStyle( 198 + color: Colors.white54, 199 + fontSize: 14, 200 + ), 201 + ), 112 202 ), 113 - ], 203 + ), 114 204 ), 115 205 ), 116 206 ),
+75 -77
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 133 133 134 134 @override 135 135 Widget build(BuildContext context) { 136 - return SafeArea( 137 - child: FutureBuilder<PostView?>( 138 - future: _loadPostWithFallback(), 139 - builder: (context, snapshot) { 140 - if (!snapshot.hasData) { 141 - return const ColoredBox( 142 - color: AppColors.black, 143 - child: Center( 144 - child: Column( 145 - mainAxisAlignment: MainAxisAlignment.center, 146 - children: [ 147 - Icon(Icons.error_outline, color: AppColors.white, size: 48), 148 - SizedBox(height: 16), 149 - Text('Failed to load post', style: TextStyle(color: AppColors.white)), 150 - ], 151 - ), 136 + return FutureBuilder<PostView?>( 137 + future: _loadPostWithFallback(), 138 + builder: (context, snapshot) { 139 + if (!snapshot.hasData) { 140 + return const ColoredBox( 141 + color: AppColors.black, 142 + child: Center( 143 + child: Column( 144 + mainAxisAlignment: MainAxisAlignment.center, 145 + children: [ 146 + Icon(Icons.error_outline, color: AppColors.white, size: 48), 147 + SizedBox(height: 16), 148 + Text('Failed to load post', style: TextStyle(color: AppColors.white)), 149 + ], 152 150 ), 153 - ); 154 - } 151 + ), 152 + ); 153 + } 155 154 156 - final post = _currentPost ?? snapshot.data!; 155 + final post = _currentPost ?? snapshot.data!; 157 156 158 - final mainContent = HeartAnimation( 159 - isAnimating: _isAnimatingHeart, 160 - onEnd: () { 161 - setState(() { 162 - _isAnimatingHeart = false; 163 - }); 164 - }, 165 - child: Stack( 166 - children: [ 167 - // Main content - only this part should detect double-tap for likes 168 - Positioned.fill( 169 - child: GestureDetector( 170 - behavior: HitTestBehavior.opaque, 171 - onDoubleTap: () => _handleDoubleTapLike(post), 172 - child: switch (post.media) { 157 + final mainContent = HeartAnimation( 158 + isAnimating: _isAnimatingHeart, 159 + onEnd: () { 160 + setState(() { 161 + _isAnimatingHeart = false; 162 + }); 163 + }, 164 + child: Stack( 165 + children: [ 166 + // Main content - only this part should detect double-tap for likes 167 + Positioned.fill( 168 + child: GestureDetector( 169 + behavior: HitTestBehavior.opaque, 170 + onDoubleTap: () => _handleDoubleTapLike(post), 171 + child: switch (post.media) { 172 + MediaViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 173 + MediaViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 174 + MediaViewImages() || MediaViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 175 + MediaViewBskyRecordWithMedia(:final media) => switch (media) { 173 176 MediaViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 174 177 MediaViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 175 178 MediaViewImages() || MediaViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 176 - MediaViewBskyRecordWithMedia(:final media) => switch (media) { 177 - MediaViewVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 178 - MediaViewBskyVideo() => PostVideoPlayer(videoUrl: post.videoUrl, thumbnail: post.thumbnailUrl), 179 - MediaViewImages() || MediaViewBskyImages() => ImageCarousel(imageUrls: post.imageUrls), 180 - _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 181 - }, 182 179 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 183 180 }, 184 - ), 181 + _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)), 182 + }, 185 183 ), 184 + ), 186 185 187 - // Overlay controls - no double-tap detection, so buttons respond immediately 188 - Positioned.fill( 189 - child: PostOverlay( 190 - post: post, 191 - isLiked: _overrideIsLiked ?? (post.viewer?.like != null), 192 - labels: post.labels ?? [], 193 - onProfilePressed: () { 194 - // No special handling needed for profile navigation in standalone feed 195 - }, 196 - onUsernameTap: () { 197 - context.router.push( 198 - ProfileRoute( 199 - did: post.author.did, 200 - initialProfile: post.author, 201 - ), 202 - ); 203 - }, 204 - ), 186 + // Overlay controls - no double-tap detection, so buttons respond immediately 187 + Positioned.fill( 188 + child: PostOverlay( 189 + post: post, 190 + isLiked: _overrideIsLiked ?? (post.viewer?.like != null), 191 + labels: post.labels ?? [], 192 + onProfilePressed: () { 193 + // No special handling needed for profile navigation in standalone feed 194 + }, 195 + onUsernameTap: () { 196 + context.router.push( 197 + ProfileRoute( 198 + did: post.author.did, 199 + initialProfile: post.author, 200 + ), 201 + ); 202 + }, 205 203 ), 206 - ], 207 - ), 208 - ); 204 + ), 205 + ], 206 + ), 207 + ); 209 208 210 - if (_showWarningOverlay) { 211 - return ContentWarningOverlay( 212 - onViewContent: () { 213 - setState(() { 214 - _showWarningOverlay = false; 215 - }); 216 - }, 217 - warningLabels: _warningLabels, 218 - shouldBlur: _shouldBlurContent, 219 - child: mainContent, 220 - ); 221 - } 209 + if (_showWarningOverlay) { 210 + return ContentWarningOverlay( 211 + onViewContent: () { 212 + setState(() { 213 + _showWarningOverlay = false; 214 + }); 215 + }, 216 + warningLabels: _warningLabels, 217 + shouldBlur: _shouldBlurContent, 218 + child: mainContent, 219 + ); 220 + } 222 221 223 - return mainContent; 224 - }, 225 - ), 222 + return mainContent; 223 + }, 226 224 ); 227 225 } 228 226 }
+2 -9
lib/src/features/profile/ui/widgets/profile_grid_widget.dart
··· 22 22 23 23 return feedState.when( 24 24 data: (state) { 25 - // Filter posts in client depending on configuration 26 - final filteredUris = () { 27 - if (both) return state.loadedPosts; 28 - if (videosOnly) { 29 - return state.loadedPosts.where((u) => state.postTypes[u] ?? true).toList(); 30 - } 31 - // images only 32 - return state.loadedPosts.where((u) => state.postTypes[u] == false).toList(); 33 - }(); 25 + // Display all posts returned by server - no client-side filtering 26 + final filteredUris = state.loadedPosts; 34 27 35 28 if (filteredUris.isEmpty) { 36 29 return [