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

reposts page

+783 -39
+6 -1
lib/src/core/design_system/components/atoms/profile_tab_item.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:sparksocial/src/core/design_system/components/atoms/tab_item.dart'; 3 + import 'package:sparksocial/src/core/design_system/tokens/gradients.dart'; 3 4 4 5 class ProfileTabItem extends StatelessWidget { 5 6 const ProfileTabItem({ ··· 19 20 Widget build(BuildContext context) { 20 21 final theme = Theme.of(context); 21 22 return AppTabItem( 22 - activeChild: filledIcon, 23 + activeChild: ShaderMask( 24 + shaderCallback: (bounds) => AppGradients.gradientLinearPrimaryGradient.createShader(bounds), 25 + blendMode: BlendMode.srcIn, 26 + child: filledIcon, 27 + ), 23 28 inactiveChild: icon, 24 29 isSelected: isSelected, 25 30 onTap: onTap,
+1 -1
lib/src/core/design_system/components/organisms/side_action_bar.dart
··· 161 161 const SizedBox(height: 13), 162 162 _ActionItem( 163 163 isActive: widget.isReposted, 164 - icon: AppIcons.repostLarge(size: 32, color: widget.isReposted ? AppColors.green : null), 164 + icon: AppIcons.repost(size: 32, color: widget.isReposted ? AppColors.green : null), 165 165 label: widget.repostCount, 166 166 onTap: widget.onRepost, 167 167 ),
+11
lib/src/core/network/atproto/data/repositories/feed_repository.dart
··· 190 190 String sort = 'latest', 191 191 String? cursor, 192 192 }); 193 + 194 + /// Get a list of posts reposted by an actor 195 + /// 196 + /// [actor] The at-identifier of the actor (handle or DID) 197 + /// [limit] The number of items to return (default 50, max 100) 198 + /// [cursor] Pagination cursor for the next set of results 199 + Future<({List<FeedViewPost> posts, String? cursor})> getActorReposts( 200 + String actor, { 201 + int limit = 50, 202 + String? cursor, 203 + }); 193 204 }
+51
lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart
··· 1350 1350 }); 1351 1351 } 1352 1352 1353 + @override 1354 + Future<({List<FeedViewPost> posts, String? cursor})> getActorReposts( 1355 + String actor, { 1356 + int limit = 50, 1357 + String? cursor, 1358 + }) async { 1359 + _logger.d('Getting actor reposts for actor: $actor, limit: $limit, cursor: $cursor'); 1360 + 1361 + return _client.executeWithRetry(() async { 1362 + if (!_client.authRepository.isAuthenticated) { 1363 + _logger.w('Not authenticated'); 1364 + throw Exception('Not authenticated'); 1365 + } 1366 + 1367 + final atproto = _client.authRepository.atproto; 1368 + if (atproto == null) { 1369 + _logger.e('AtProto not initialized'); 1370 + throw Exception('AtProto not initialized'); 1371 + } 1372 + 1373 + final parameters = <String, dynamic>{ 1374 + 'actor': actor, 1375 + 'limit': limit, 1376 + }; 1377 + 1378 + if (cursor != null) { 1379 + parameters['cursor'] = cursor; 1380 + } 1381 + 1382 + final result = await atproto.get( 1383 + NSID.parse('so.sprk.feed.getActorReposts'), 1384 + parameters: parameters, 1385 + headers: {'atproto-proxy': _client.sprkDid}, 1386 + to: (jsonMap) { 1387 + final rawFeed = jsonMap['feed']! as List<dynamic>; 1388 + final feedPosts = _parseAndFilterPosts<FeedViewPost>( 1389 + rawPosts: rawFeed, 1390 + fromJson: FeedViewPost.fromJson, 1391 + hasMedia: _feedViewPostHasMedia, 1392 + getUri: _getFeedViewPostUri, 1393 + source: 'sprk actor reposts', 1394 + ); 1395 + return (posts: feedPosts, cursor: jsonMap['cursor'] as String?); 1396 + }, 1397 + adaptor: (uint8) => jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 1398 + ); 1399 + _logger.d('Actor reposts retrieved successfully: ${result.data.posts.length} posts'); 1400 + return result.data; 1401 + }); 1402 + } 1403 + 1353 1404 /// Helper method to determine content type based on file extension 1354 1405 String _getContentType(String videoPath) { 1355 1406 final extension = path.extension(videoPath).toLowerCase();
+1
lib/src/core/routing/app_router.dart
··· 110 110 // Deep linking routes or routes that will be pushed on top of everything 111 111 AutoRoute(page: StandalonePostRoute.page, path: '/post/:postUri'), 112 112 AutoRoute(page: StandaloneProfileFeedRoute.page, path: '/profile-feed'), 113 + AutoRoute(page: StandaloneRepostsFeedRoute.page, path: '/reposts-feed'), 113 114 AutoRoute( 114 115 page: ProfileRoute.page, 115 116 path: '/profile/:did',
+1
lib/src/core/routing/pages.dart
··· 20 20 export 'package:sparksocial/src/features/profile/ui/pages/edit_profile_page.dart'; 21 21 export 'package:sparksocial/src/features/profile/ui/pages/profile_page.dart'; 22 22 export 'package:sparksocial/src/features/profile/ui/pages/standalone_profile_feed_page.dart'; 23 + export 'package:sparksocial/src/features/profile/ui/pages/standalone_reposts_feed_page.dart'; 23 24 export 'package:sparksocial/src/features/profile/ui/pages/user_profile_page.dart'; 24 25 export 'package:sparksocial/src/features/search/ui/pages/search_page.dart'; 25 26 export 'package:sparksocial/src/features/settings/ui/pages/feed_list_page.dart';
+239
lib/src/features/profile/providers/profile_reposts_provider.dart
··· 1 + import 'dart:collection'; 2 + 3 + import 'package:atproto/com_atproto_label_defs.dart'; 4 + import 'package:atproto_core/atproto_core.dart'; 5 + import 'package:get_it/get_it.dart'; 6 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 7 + import 'package:sparksocial/src/core/network/atproto/data/models/models.dart'; 8 + import 'package:sparksocial/src/core/network/atproto/data/repositories/feed_repository.dart'; 9 + import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 10 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 11 + import 'package:sparksocial/src/core/utils/logging/logger.dart'; 12 + import 'package:sparksocial/src/features/profile/providers/profile_feed_state.dart'; 13 + import 'package:sparksocial/src/features/settings/providers/settings_provider.dart'; 14 + 15 + part 'profile_reposts_provider.g.dart'; 16 + 17 + @riverpod 18 + class ProfileReposts extends _$ProfileReposts { 19 + final FeedRepository _feedRepository = GetIt.instance<SprkRepository>().feed; 20 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger('ProfileReposts'); 21 + bool _isLoading = false; 22 + late final String _actor; 23 + 24 + @override 25 + Future<ProfileFeedState> build(String actor) async { 26 + _actor = actor; 27 + try { 28 + final result = await _loadReposts( 29 + actor: actor, 30 + cursor: null, 31 + ); 32 + return result; 33 + } catch (e, stackTrace) { 34 + _logger.e('Error loading initial reposts: $e', error: e, stackTrace: stackTrace); 35 + rethrow; 36 + } 37 + } 38 + 39 + /// Load reposts from Spark API 40 + Future<ProfileFeedState> _loadReposts({ 41 + required String actor, 42 + required String? cursor, 43 + ProfileFeedState? currentState, 44 + }) async { 45 + final postSources = Map<AtUri, String>.from(currentState?.postSources ?? {}); 46 + final postTypes = Map<AtUri, bool>.from(currentState?.postTypes ?? {}); 47 + final postViews = Map<AtUri, PostView>.from(currentState?.postViews ?? {}); 48 + final allPosts = List<AtUri>.from(currentState?.allPosts ?? []); 49 + 50 + final newPosts = <PostView>[]; 51 + 52 + // Fetch from Spark API 53 + final result = await _fetchFromSource( 54 + (cursor) => _feedRepository.getActorReposts(actor, limit: ProfileFeedState.fetchLimit, cursor: cursor), 55 + cursor, 56 + 'ActorReposts', 57 + ); 58 + 59 + for (final feedViewPost in result.posts) { 60 + final uri = feedViewPost.uri; 61 + if (!postViews.containsKey(uri)) { 62 + final postView = feedViewPost.asPost; 63 + if (postView != null) { 64 + newPosts.add(postView); 65 + // Determine source based on URI collection 66 + final isBlueskyPost = uri.collection.toString().startsWith('app.bsky'); 67 + postSources[uri] = isBlueskyPost ? 'bsky' : 'sprk'; 68 + postTypes[uri] = postView.videoUrl.isNotEmpty; 69 + postViews[uri] = postView; 70 + } 71 + } 72 + } 73 + 74 + newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 75 + allPosts.addAll(newPosts.map((post) => post.uri)); 76 + 77 + // Get additional labels from followed labelers for new posts 78 + if (newPosts.isNotEmpty) { 79 + try { 80 + final settings = ref.read(settingsProvider.notifier); 81 + final followedLabelers = await settings.getLabelers(); 82 + final newPostUris = newPosts.map((post) => post.uri).toList(); 83 + final (cursor: _, labels: additionalLabels) = await _feedRepository.getLabels(newPostUris, sources: followedLabelers); 84 + // Add the additional labels to the posts 85 + for (final label in additionalLabels) { 86 + final uri = AtUri.parse(label.uri); 87 + final post = postViews[uri]; 88 + if (post != null) { 89 + final existingLabels = post.labels != null ? List<Label>.from(post.labels!) : <Label>[]; 90 + existingLabels.add(label); 91 + postViews[uri] = post.copyWith(labels: existingLabels); 92 + } 93 + } 94 + } catch (e) { 95 + _logger.e('Error fetching additional labels: $e'); 96 + } 97 + } 98 + 99 + // Client-side components decide whether to show videos/images/all. 100 + // Here we only apply label-based filtering and return all posts. 101 + final filteredPosts = await _filterHiddenPosts(allPosts, postViews); 102 + 103 + // End of network when: 104 + // 1. API returns null cursor (no more pages) 105 + // 2. API returns fewer posts than requested (last page) 106 + // 3. No new posts were added (duplicates or empty response) 107 + final isEndOfNetwork = 108 + result.cursor == null || 109 + result.posts.length < ProfileFeedState.fetchLimit || 110 + (currentState != null && currentState.allPosts.length == allPosts.length); 111 + 112 + return ProfileFeedState( 113 + loadedPosts: filteredPosts, 114 + allPosts: allPosts, 115 + isEndOfNetwork: isEndOfNetwork, 116 + cursor: result.cursor, 117 + // ignore: prefer_collection_literals 118 + extraInfo: currentState?.extraInfo ?? LinkedHashMap(), 119 + postSources: postSources, 120 + postTypes: postTypes, 121 + postViews: postViews, 122 + ); 123 + } 124 + 125 + Future<({List<FeedViewPost> posts, String? cursor})> _fetchFromSource( 126 + Future<({List<FeedViewPost> posts, String? cursor})> Function(String? cursor) fetcher, 127 + String? cursor, 128 + String sourceName, 129 + ) async { 130 + try { 131 + final result = await fetcher(cursor); 132 + return result; 133 + } catch (e, stackTrace) { 134 + _logger.e('Failed to load from $sourceName: $e', error: e, stackTrace: stackTrace); 135 + return (posts: <FeedViewPost>[], cursor: cursor); 136 + } 137 + } 138 + 139 + Future<void> loadMore() async { 140 + if (_isLoading || (state.value?.isEndOfNetwork ?? true)) return; 141 + 142 + _isLoading = true; 143 + final currentState = state.value; 144 + if (currentState == null) { 145 + _isLoading = false; 146 + return; 147 + } 148 + 149 + try { 150 + final result = await _loadReposts( 151 + actor: _actor, 152 + cursor: currentState.cursor, 153 + currentState: currentState, 154 + ); 155 + 156 + state = AsyncValue.data(result); 157 + } catch (e) { 158 + _logger.e('Error loading more reposts: $e'); 159 + state = AsyncValue.error(e, StackTrace.current); 160 + } finally { 161 + _isLoading = false; 162 + } 163 + } 164 + 165 + Future<void> refresh() async { 166 + try { 167 + final result = await _loadReposts( 168 + actor: _actor, 169 + cursor: null, 170 + ); 171 + state = AsyncValue.data(result); 172 + } catch (e) { 173 + _logger.e('Error refreshing reposts: $e'); 174 + state = AsyncValue.error(e, StackTrace.current); 175 + } 176 + } 177 + 178 + /// Checks if a post should be hidden based on its labels and user preferences 179 + Future<bool> _shouldHidePost(AtUri uri, List<Label> postLabels) async { 180 + final settings = ref.read(settingsProvider.notifier); 181 + for (final label in postLabels) { 182 + try { 183 + final labelPreference = await settings.getLabelPreference(label.val); 184 + if (labelPreference.setting == Setting.hide || labelPreference.adultOnly) { 185 + return true; 186 + } 187 + } catch (e) { 188 + // Label preference not found, continue checking other labels 189 + continue; 190 + } 191 + } 192 + return false; 193 + } 194 + 195 + /// Filters URIs based on label preferences, removing posts that should be hidden 196 + Future<List<AtUri>> _filterHiddenPosts( 197 + List<AtUri> uris, 198 + Map<AtUri, PostView> postViews, 199 + ) async { 200 + final filteredUris = <AtUri>[]; 201 + 202 + for (final uri in uris) { 203 + final postView = postViews[uri]; 204 + if (postView != null) { 205 + // Collect all labels for this post 206 + final postLabels = <Label>[]; 207 + 208 + // Add labels from the post itself 209 + if (postView.labels != null) { 210 + postLabels.addAll(postView.labels!); 211 + } 212 + 213 + // Add self labels from the post record 214 + if (postView.record.selfLabels != null) { 215 + for (final selfLabel in postView.record.selfLabels!) { 216 + postLabels.add( 217 + Label( 218 + uri: postView.uri.toString(), 219 + val: selfLabel.val, 220 + src: postView.uri.toString(), 221 + cts: postView.indexedAt, 222 + ), 223 + ); 224 + } 225 + } 226 + 227 + final shouldHide = await _shouldHidePost(uri, postLabels); 228 + if (!shouldHide) { 229 + filteredUris.add(uri); 230 + } 231 + } else { 232 + // No post view means no labels, so include the post 233 + filteredUris.add(uri); 234 + } 235 + } 236 + 237 + return filteredUris; 238 + } 239 + }
+71 -35
lib/src/features/profile/ui/pages/profile_page.dart
··· 22 22 import 'package:sparksocial/src/core/utils/text_formatter.dart'; 23 23 import 'package:sparksocial/src/features/profile/providers/profile_feed_provider.dart'; 24 24 import 'package:sparksocial/src/features/profile/providers/profile_provider.dart'; 25 + import 'package:sparksocial/src/features/profile/providers/profile_reposts_provider.dart'; 25 26 import 'package:sparksocial/src/features/profile/ui/pages/user_list_page.dart'; 26 27 import 'package:sparksocial/src/features/profile/ui/widgets/early_supporter_sheet.dart'; 27 28 import 'package:sparksocial/src/features/profile/ui/widgets/profile_grid_tab.dart'; 29 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_reposts_tab.dart'; 30 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_tab_base.dart'; 28 31 29 32 @RoutePage() 30 33 class ProfilePage extends ConsumerStatefulWidget { ··· 47 50 late final SparkLogger _logger = GetIt.instance<LogService>().getLogger('ProfilePage'); 48 51 late final IdentityRepository _identityRepository = GetIt.instance<IdentityRepository>(); 49 52 late final ScrollController _scrollController = ScrollController(); 53 + int _activeTabIndex = 0; 54 + final Map<int, ProfileTabBase> _cachedTabWidgets = {}; 50 55 51 56 @override 52 57 void initState() { ··· 65 70 // Trigger loading when user is within ~2 rows of the bottom (each row is roughly 200px at 9:16 aspect ratio) 66 71 if (_scrollController.hasClients && _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 500) { 67 72 final profileUri = AtUri.parse('at://${widget.did}'); 68 - ref.read(profileFeedProvider(profileUri, false).notifier).loadMore(); 73 + if (_activeTabIndex == 0) { 74 + ref.read(profileFeedProvider(profileUri, false).notifier).loadMore(); 75 + } else if (_activeTabIndex == 1) { 76 + final actor = profileUri.hostname; 77 + ref.read(profileRepostsProvider(actor).notifier).loadMore(); 78 + } 69 79 } 70 80 } 71 81 72 - /// Builds slivers for a given tab index 73 - /// Tab 0 is built directly (default profile content), other tabs use route pages 74 - List<Widget> _buildSliversForTab({ 75 - required BuildContext context, 76 - required WidgetRef ref, 77 - required int tabIndex, 78 - }) { 82 + /// Gets or creates a tab widget for the given index 83 + /// Caches tab widgets to keep them loaded when switching tabs 84 + ProfileTabBase _getTabWidget(int tabIndex) { 85 + if (_cachedTabWidgets.containsKey(tabIndex)) { 86 + return _cachedTabWidgets[tabIndex]!; 87 + } 88 + 79 89 final profileUri = AtUri.parse('at://${widget.did}'); 90 + ProfileTabBase tabWidget; 80 91 81 92 switch (tabIndex) { 82 93 case 0: 83 94 // First tab - default profile grid content (not a route) 84 - final gridTab = ProfileGridTab(profileUri: profileUri); 85 - return gridTab.buildSlivers(context, ref); 86 - // Add more tabs here - these correspond to route pages: 87 - // case 1: 88 - // return ProfileLikedPage.buildSlivers(context, ref, widget.did); 89 - // case 2: 90 - // return ProfileVideosOnlyPage.buildSlivers(context, ref, widget.did); 95 + tabWidget = ProfileGridTab(profileUri: profileUri); 96 + break; 97 + case 1: 98 + // Second tab - reposts 99 + tabWidget = ProfileRepostsTab(profileUri: profileUri); 100 + break; 91 101 default: 92 102 // Fallback to first tab 93 - final gridTab = ProfileGridTab(profileUri: profileUri); 94 - return gridTab.buildSlivers(context, ref); 103 + tabWidget = ProfileGridTab(profileUri: profileUri); 95 104 } 105 + 106 + // Cache the tab widget to keep it loaded 107 + _cachedTabWidgets[tabIndex] = tabWidget; 108 + return tabWidget; 109 + } 110 + 111 + /// Builds slivers for a given tab index 112 + /// Tab 0 is built directly (default profile content), other tabs use route pages 113 + List<Widget> _buildSliversForTab({ 114 + required BuildContext context, 115 + required WidgetRef ref, 116 + required int tabIndex, 117 + }) { 118 + final tabWidget = _getTabWidget(tabIndex); 119 + return tabWidget.buildSlivers(context, ref); 96 120 } 97 121 98 122 void _showEarlySupporterInfo(BuildContext context) { ··· 142 166 143 167 // Tab 0 is the default profile content (built directly, not a route) 144 168 // Tabs 1+ are subpages (route pages) 145 - // For now we only have tab 0, so we use simple state management 146 - // When adding tabs 1+, use AutoTabsRouter with those routes 169 + // Initialize all tabs to cache their widgets 170 + final profileUri = AtUri.parse('at://${widget.did}'); 171 + _getTabWidget(0); 172 + _getTabWidget(1); 147 173 148 - // Build slivers for tab 0 immediately - tabs load in parallel with profile 174 + // Watch all tab providers to keep their state alive even when not visible 175 + // This ensures tabs don't reload when switching between them 176 + ref.watch(profileFeedProvider(profileUri, false)); 177 + final actor = profileUri.hostname; 178 + ref.watch(profileRepostsProvider(actor)); 179 + 180 + // Build slivers for the active tab using cached widget 149 181 final contentSlivers = _buildSliversForTab( 150 182 context: context, 151 183 ref: ref, 152 - tabIndex: 0, // Always tab 0 for now 184 + tabIndex: _activeTabIndex, 153 185 ); 154 186 155 187 return profileStateAsync.when( ··· 366 398 ), 367 399 ], 368 400 tabsWidget: ProfileTabBar( 369 - selectedIndex: 0, // Always 0 for now 370 - tabs: _buildTabItems(0), 401 + selectedIndex: _activeTabIndex, 402 + tabs: _buildTabItems(_activeTabIndex), 371 403 ), 372 404 onTabChanged: (index) { 373 - // When adding tabs 1+ with AutoTabsRouter, this will be: tabsRouter.setActiveIndex(index) 374 - // For now with only tab 0, this is a no-op 405 + setState(() { 406 + _activeTabIndex = index; 407 + }); 375 408 }, 376 409 contentWidget: const SizedBox.shrink(), // Not used when contentSlivers is provided 377 410 contentSlivers: contentSlivers, ··· 405 438 ), 406 439 ], 407 440 tabsWidget: ProfileTabBar( 408 - selectedIndex: 0, // Always 0 for now 409 - tabs: _buildTabItems(0), 441 + selectedIndex: _activeTabIndex, 442 + tabs: _buildTabItems(_activeTabIndex), 410 443 ), 411 444 contentWidget: const SizedBox.shrink(), // Not used when contentSlivers is provided 412 445 contentSlivers: contentSlivers, // Tabs load even while profile is loading ··· 432 465 filledIcon: AppIcons.gridFilled(), 433 466 isSelected: activeIndex == 0, 434 467 onTap: () { 435 - // When using AutoTabsRouter: tabsRouter.setActiveIndex(0) 436 468 setState(() { 437 - // activeTabIndex = 0; // Will be handled by state management 469 + _activeTabIndex = 0; 470 + }); 471 + }, 472 + ), 473 + ProfileTabItem( 474 + icon: AppIcons.repost(), 475 + filledIcon: AppIcons.repost(), // No filled variant exists, use same icon 476 + isSelected: activeIndex == 1, 477 + onTap: () { 478 + setState(() { 479 + _activeTabIndex = 1; 438 480 }); 439 481 }, 440 482 ), ··· 442 484 // ProfileTabItem( 443 485 // icon: AppIcons.profileLiked(), 444 486 // filledIcon: AppIcons.likeFilled(), 445 - // isSelected: activeIndex == 1, 446 - // onTap: () => tabsRouter.setActiveIndex(1), 447 - // ), 448 - // ProfileTabItem( 449 - // icon: AppIcons.video(), 450 - // filledIcon: AppIcons.videoFilled(), 451 487 // isSelected: activeIndex == 2, 452 488 // onTap: () => tabsRouter.setActiveIndex(2), 453 489 // ),
+4 -2
lib/src/features/profile/ui/pages/standalone_profile_feed_page.dart
··· 51 51 52 52 @override 53 53 Widget build(BuildContext context) { 54 - // Initialize the index provider SYNCHRONOUSLY before any child widgets build. 54 + // Initialize the index provider after the build phase to avoid modifying providers during build. 55 55 // This prevents race conditions where video widgets see the default index (0) 56 56 // before the correct initial index is set. 57 57 if (!_hasInitializedIndex) { 58 58 _hasInitializedIndex = true; 59 - ref.read(profileFeedIndexProvider(widget.profileUri).notifier).setIndex(widget.initialPostIndex); 59 + WidgetsBinding.instance.addPostFrameCallback((_) { 60 + ref.read(profileFeedIndexProvider(widget.profileUri).notifier).setIndex(widget.initialPostIndex); 61 + }); 60 62 } 61 63 62 64 final feedState = ref.watch(profileFeedProvider(profileAtUri, widget.videosOnly));
+222
lib/src/features/profile/ui/pages/standalone_reposts_feed_page.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:auto_route/auto_route.dart'; 5 + import 'package:flutter/material.dart'; 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'; 9 + import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 10 + import 'package:sparksocial/src/features/feed/ui/widgets/feed/cacheable_page_view.dart'; 11 + import 'package:sparksocial/src/features/feed/ui/widgets/feed/snappy_page_scroll_physics.dart'; 12 + import 'package:sparksocial/src/features/profile/providers/profile_feed_index_provider.dart'; 13 + import 'package:sparksocial/src/features/profile/providers/profile_reposts_provider.dart'; 14 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_feed_post_widget.dart'; 15 + 16 + @RoutePage() 17 + class StandaloneRepostsFeedPage extends ConsumerStatefulWidget { 18 + const StandaloneRepostsFeedPage({ 19 + required this.actor, 20 + required this.initialPostIndex, 21 + super.key, 22 + }); 23 + final String actor; 24 + final int initialPostIndex; 25 + 26 + @override 27 + ConsumerState<StandaloneRepostsFeedPage> createState() => _StandaloneRepostsFeedPageState(); 28 + } 29 + 30 + class _StandaloneRepostsFeedPageState extends ConsumerState<StandaloneRepostsFeedPage> { 31 + late final PageController pageController; 32 + int _currentIndex = 0; 33 + bool _hasInitializedIndex = false; 34 + 35 + @override 36 + void initState() { 37 + super.initState(); 38 + _currentIndex = widget.initialPostIndex; 39 + pageController = PageController(initialPage: widget.initialPostIndex); 40 + } 41 + 42 + @override 43 + void dispose() { 44 + pageController.dispose(); 45 + super.dispose(); 46 + } 47 + 48 + @override 49 + Widget build(BuildContext context) { 50 + // Initialize the index provider after the build phase to avoid modifying providers during build. 51 + // This prevents race conditions where video widgets see the default index (0) 52 + // before the correct initial index is set. 53 + if (!_hasInitializedIndex) { 54 + _hasInitializedIndex = true; 55 + WidgetsBinding.instance.addPostFrameCallback((_) { 56 + ref.read(profileFeedIndexProvider('reposts:${widget.actor}').notifier).setIndex(widget.initialPostIndex); 57 + }); 58 + } 59 + 60 + final repostsState = ref.watch(profileRepostsProvider(widget.actor)); 61 + final bottomPadding = MediaQuery.of(context).padding.bottom; 62 + 63 + return Scaffold( 64 + backgroundColor: AppColors.black, 65 + body: Stack( 66 + children: [ 67 + // Full-screen content 68 + repostsState.when( 69 + data: (state) { 70 + // Display all posts returned by server - no client-side filtering 71 + final filteredUris = state.loadedPosts; 72 + 73 + if (filteredUris.isEmpty) { 74 + return const Center( 75 + child: Text('No reposts available', style: TextStyle(color: AppColors.white)), 76 + ); 77 + } 78 + 79 + return CacheablePageView.builder( 80 + cachePageExtent: 1, 81 + controller: pageController, 82 + scrollDirection: Axis.vertical, 83 + physics: const SnappyPageScrollPhysics(), 84 + allowImplicitScrolling: true, 85 + itemCount: filteredUris.length, 86 + onPageChanged: (index) { 87 + setState(() { 88 + _currentIndex = index; 89 + }); 90 + // Update the profile feed index provider for video visibility tracking 91 + ref.read(profileFeedIndexProvider('reposts:${widget.actor}').notifier).setIndex(index); 92 + // Load more posts when approaching the end 93 + if (index >= filteredUris.length - 3 && !state.isEndOfNetwork) { 94 + ref.read(profileRepostsProvider(widget.actor).notifier).loadMore(); 95 + } 96 + }, 97 + itemBuilder: (context, index) { 98 + final postUri = filteredUris[index]; 99 + final post = state.postViews[postUri]; 100 + // Create a profile URI from the actor for the post widget 101 + final profileUri = AtUri.parse('at://${widget.actor}'); 102 + return ProfileFeedPostWidget( 103 + postUri: postUri, 104 + profileUri: profileUri, 105 + videosOnly: false, 106 + post: post, 107 + index: index, 108 + ); 109 + }, 110 + ); 111 + }, 112 + loading: () => const Center(child: CircularProgressIndicator(color: AppColors.white)), 113 + error: (error, stack) => Center( 114 + child: Column( 115 + mainAxisAlignment: MainAxisAlignment.center, 116 + children: [ 117 + const Icon(Icons.error_outline, color: AppColors.white, size: 48), 118 + const SizedBox(height: 16), 119 + Text( 120 + 'Error loading reposts: $error', 121 + style: const TextStyle(color: AppColors.white), 122 + textAlign: TextAlign.center, 123 + ), 124 + const SizedBox(height: 16), 125 + ElevatedButton( 126 + onPressed: () { 127 + ref.read(profileRepostsProvider(widget.actor).notifier).refresh(); 128 + }, 129 + child: const Text('Retry'), 130 + ), 131 + ], 132 + ), 133 + ), 134 + ), 135 + // Back button overlay - respects safe area for the button only 136 + Positioned( 137 + top: 0, 138 + left: 0, 139 + child: SafeArea( 140 + child: Padding( 141 + padding: const EdgeInsets.all(8), 142 + child: IconButton( 143 + icon: const Icon(Icons.arrow_back, color: AppColors.white), 144 + onPressed: () => context.router.maybePop(), 145 + ), 146 + ), 147 + ), 148 + ), 149 + ], 150 + ), 151 + bottomNavigationBar: _CommentBar( 152 + bottomPadding: bottomPadding, 153 + onTap: () { 154 + final state = repostsState.value; 155 + if (state != null && state.loadedPosts.isNotEmpty) { 156 + final currentPostUri = state.loadedPosts[_currentIndex]; 157 + final post = state.postViews[currentPostUri]; 158 + if (post != null) { 159 + context.router.push(CommentsRoute(postUri: post.uri.toString(), isSprk: post.isSprk, post: post)); 160 + } 161 + } 162 + }, 163 + ), 164 + ); 165 + } 166 + } 167 + 168 + class _CommentBar extends StatelessWidget { 169 + const _CommentBar({ 170 + required this.bottomPadding, 171 + required this.onTap, 172 + }); 173 + 174 + final double bottomPadding; 175 + final VoidCallback onTap; 176 + 177 + @override 178 + Widget build(BuildContext context) { 179 + return ClipRRect( 180 + child: BackdropFilter( 181 + filter: ImageFilter.blur( 182 + sigmaX: AppConstants.blurBottomBar.toDouble(), 183 + sigmaY: AppConstants.blurBottomBar.toDouble(), 184 + ), 185 + child: DecoratedBox( 186 + decoration: BoxDecoration( 187 + color: const Color.fromARGB(51, 0, 0, 0), 188 + border: Border( 189 + top: BorderSide(color: Colors.white.withValues(alpha: 0.08), width: 2), 190 + ), 191 + ), 192 + child: GestureDetector( 193 + behavior: HitTestBehavior.opaque, 194 + onTap: onTap, 195 + child: Container( 196 + padding: EdgeInsets.only( 197 + left: 16, 198 + right: 16, 199 + top: 12, 200 + bottom: 12 + bottomPadding, 201 + ), 202 + child: Container( 203 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 204 + decoration: BoxDecoration( 205 + color: Colors.white.withValues(alpha: 0.1), 206 + borderRadius: BorderRadius.circular(24), 207 + ), 208 + child: const Text( 209 + 'Add comment...', 210 + style: TextStyle( 211 + color: Colors.white54, 212 + fontSize: 14, 213 + ), 214 + ), 215 + ), 216 + ), 217 + ), 218 + ), 219 + ), 220 + ); 221 + } 222 + }
+176
lib/src/features/profile/ui/widgets/profile_reposts_tab.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:auto_route/auto_route.dart'; 3 + import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 6 + import 'package:skeletonizer/skeletonizer.dart'; 7 + import 'package:sparksocial/src/core/routing/app_router.dart'; 8 + import 'package:sparksocial/src/features/profile/providers/profile_reposts_provider.dart'; 9 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_grid_widget.dart'; 10 + import 'package:sparksocial/src/features/profile/ui/widgets/profile_tab_base.dart'; 11 + 12 + /// Tab widget that displays reposted posts in a grid 13 + class ProfileRepostsTab extends ProfileTabBase { 14 + const ProfileRepostsTab({ 15 + required this.profileUri, 16 + super.key, 17 + }); 18 + 19 + final AtUri profileUri; 20 + 21 + @override 22 + List<Widget> buildSlivers(BuildContext context, WidgetRef ref) { 23 + // Extract actor identifier from profileUri (DID or handle) 24 + final actor = profileUri.hostname; 25 + 26 + void onPostTap(BuildContext context, WidgetRef ref, AtUri postUri) { 27 + final repostsState = ref.read(profileRepostsProvider(actor)); 28 + repostsState.whenData((repostsState) { 29 + final filteredUris = repostsState.loadedPosts; 30 + final postIndex = filteredUris.indexOf(postUri); 31 + if (postIndex != -1) { 32 + context.router.push( 33 + StandaloneRepostsFeedRoute( 34 + actor: actor, 35 + initialPostIndex: postIndex, 36 + ), 37 + ); 38 + } else { 39 + context.router.push(StandalonePostRoute(postUri: postUri.toString())); 40 + } 41 + }); 42 + } 43 + 44 + return _buildRepostsGridSlivers( 45 + context: context, 46 + ref: ref, 47 + actor: actor, 48 + onPostTap: onPostTap, 49 + ); 50 + } 51 + 52 + @override 53 + Widget build(BuildContext context, WidgetRef ref) { 54 + // This widget is used by route pages to build slivers 55 + // The actual rendering happens in ProfilePageTemplate via buildSlivers() 56 + return const SizedBox.shrink(); 57 + } 58 + 59 + /// Builder function that creates slivers for the reposts grid 60 + List<Widget> _buildRepostsGridSlivers({ 61 + required BuildContext context, 62 + required WidgetRef ref, 63 + required String actor, 64 + required Function(BuildContext, WidgetRef, AtUri) onPostTap, 65 + }) { 66 + final repostsState = ref.watch(profileRepostsProvider(actor)); 67 + 68 + return repostsState.when( 69 + data: (state) { 70 + // Display all posts returned by server - no client-side filtering 71 + final filteredUris = state.loadedPosts; 72 + 73 + if (filteredUris.isEmpty) { 74 + return [ 75 + SliverFillRemaining( 76 + child: Center( 77 + child: Column( 78 + mainAxisAlignment: MainAxisAlignment.center, 79 + children: [ 80 + Icon( 81 + FluentIcons.arrow_repeat_all_24_regular, 82 + size: 48, 83 + color: Theme.of(context).colorScheme.onSurfaceVariant, 84 + ), 85 + const SizedBox(height: 16), 86 + Text( 87 + 'No reposts yet', 88 + style: Theme.of(context).textTheme.bodyLarge?.copyWith( 89 + color: Theme.of(context).colorScheme.onSurfaceVariant, 90 + ), 91 + ), 92 + ], 93 + ), 94 + ), 95 + ), 96 + ]; 97 + } 98 + 99 + return [ 100 + SliverPadding( 101 + padding: const EdgeInsets.all(5), 102 + sliver: SliverGrid( 103 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 104 + crossAxisCount: 3, 105 + crossAxisSpacing: 5, 106 + mainAxisSpacing: 5, 107 + childAspectRatio: 9 / 16, 108 + ), 109 + delegate: SliverChildBuilderDelegate( 110 + (context, index) { 111 + final postUri = filteredUris[index]; 112 + final postView = state.postViews[postUri]; 113 + final postSource = state.postSources[postUri]; 114 + 115 + if (postView == null) { 116 + return const SizedBox.shrink(); 117 + } 118 + 119 + return ProfileGridTile( 120 + postView: postView, 121 + postSource: postSource, 122 + onTap: () => onPostTap(context, ref, postUri), 123 + ); 124 + }, 125 + childCount: filteredUris.length, 126 + ), 127 + ), 128 + ), 129 + ]; 130 + }, 131 + loading: () => [ 132 + SliverPadding( 133 + padding: const EdgeInsets.all(5), 134 + sliver: SliverGrid( 135 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( 136 + crossAxisCount: 3, 137 + crossAxisSpacing: 5, 138 + mainAxisSpacing: 5, 139 + childAspectRatio: 9 / 16, 140 + ), 141 + delegate: SliverChildBuilderDelegate( 142 + (context, index) => Skeletonizer( 143 + child: Container( 144 + decoration: BoxDecoration( 145 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 146 + borderRadius: BorderRadius.circular(15), 147 + ), 148 + ), 149 + ), 150 + childCount: 12, 151 + ), 152 + ), 153 + ), 154 + ], 155 + error: (error, stack) => [ 156 + SliverFillRemaining( 157 + child: Center( 158 + child: Column( 159 + mainAxisAlignment: MainAxisAlignment.center, 160 + children: [ 161 + const Icon(FluentIcons.error_circle_24_regular, size: 48), 162 + const SizedBox(height: 16), 163 + Text('Error loading reposts: $error'), 164 + const SizedBox(height: 16), 165 + ElevatedButton( 166 + onPressed: () => ref.read(profileRepostsProvider(actor).notifier).refresh(), 167 + child: const Text('Retry'), 168 + ), 169 + ], 170 + ), 171 + ), 172 + ), 173 + ], 174 + ); 175 + } 176 + }