mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

feat: add dedicated quotes/reposts tabs on a profile

* separate liked and bookmark screens

* distinguish between local and cloud/protocol level saves

+1209 -189
+4
CHANGELOG.md
··· 86 86 - AppView (BlueSky or BlackSky) based routing with swappable provider from Login or 87 87 Settings 88 88 - Trending views and feeds/listings based on AppView. 89 + 90 + #### 2026-05-01 91 + 92 + - Separate views for local and protocol-level saved/bookmarked posts.
+14 -2
lib/core/router/app_router.dart
··· 209 209 }, 210 210 ), 211 211 GoRoute( 212 - path: '/saved', 213 - pageBuilder: (context, state) => _page(context, state, SavedPostsScreen(accountDid: context.read<String>())), 212 + path: '/bookmarks', 213 + pageBuilder: (context, state) => _page( 214 + context, 215 + state, 216 + SavedPostsScreen(accountDid: context.read<String>(), initialTab: SavedPostsInitialTab.bookmarks), 217 + ), 218 + ), 219 + GoRoute( 220 + path: '/liked', 221 + pageBuilder: (context, state) => _page( 222 + context, 223 + state, 224 + SavedPostsScreen(accountDid: context.read<String>(), initialTab: SavedPostsInitialTab.liked), 225 + ), 214 226 ), 215 227 GoRoute(path: '/lists', pageBuilder: (context, state) => _page(context, state, const MyListsScreen())), 216 228 GoRoute(
+25
lib/features/feed/cubit/saved_posts_cubit.dart
··· 167 167 } 168 168 } 169 169 170 + /// Clears local bookmark state only. 171 + /// 172 + /// - local -> deleted 173 + /// - both -> downgraded to cloud 174 + /// - cloud -> unchanged 175 + Future<void> clearLocalSaved() async { 176 + try { 177 + final posts = await _database.getSavedPosts(_accountDid); 178 + for (final post in posts) { 179 + if (post.saveType == 'local') { 180 + await _database.unsavePostById(post.id); 181 + _semanticIndexer?.removePost(post.postUri); 182 + continue; 183 + } 184 + if (post.saveType == 'both') { 185 + await _database.updateSaveType(_accountDid, post.postUri, 'cloud'); 186 + } 187 + } 188 + await loadSavedPosts(); 189 + } catch (error) { 190 + log.e('Failed to clear local saved posts', error: error); 191 + emit(state.copyWith(error: 'Failed to clear local bookmarks')); 192 + } 193 + } 194 + 170 195 Stream<bool> watchIsSaved(String postUri) { 171 196 return _database.watchIsPostSaved(_accountDid, postUri); 172 197 }
+5
lib/features/feed/data/feed_repository.dart
··· 359 359 } 360 360 361 361 enum FeedFilter { 362 + postsWithReplies, 362 363 postsNoReplies, 363 364 postsWithMedia, 364 365 postsAndAuthorThreads; 365 366 366 367 String get emptyLabel { 367 368 switch (this) { 369 + case FeedFilter.postsWithReplies: 370 + return 'No posts or replies yet'; 368 371 case FeedFilter.postsNoReplies: 369 372 return 'No posts yet'; 370 373 case FeedFilter.postsAndAuthorThreads: ··· 376 379 377 380 FeedGetAuthorFeedFilter get bskyFilter { 378 381 switch (this) { 382 + case FeedFilter.postsWithReplies: 383 + return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_with_replies); 379 384 case FeedFilter.postsNoReplies: 380 385 return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_no_replies); 381 386 case FeedFilter.postsWithMedia:
+370 -72
lib/features/feed/presentation/saved_posts_screen.dart
··· 1 1 import 'dart:convert'; 2 - import 'package:lazurite/core/theme/theme_extensions.dart'; 3 2 4 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 4 import 'package:flutter/material.dart'; ··· 9 8 import 'package:lazurite/core/logging/app_logger.dart'; 10 9 import 'package:lazurite/core/network/app_view_provider.dart'; 11 10 import 'package:lazurite/core/network/app_view_web_links.dart'; 11 + import 'package:lazurite/core/theme/theme_extensions.dart'; 12 12 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 13 + import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; 13 14 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 14 15 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 15 16 import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 16 17 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 18 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 17 19 import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 18 20 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 19 21 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 20 22 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 21 - import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 22 23 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 24 + 25 + enum SavedPostsInitialTab { bookmarks, liked, search } 23 26 24 27 class SavedPostsScreen extends StatelessWidget { 25 - const SavedPostsScreen({super.key, required this.accountDid}); 28 + const SavedPostsScreen({super.key, required this.accountDid, this.initialTab = SavedPostsInitialTab.bookmarks}); 26 29 27 30 final String accountDid; 31 + final SavedPostsInitialTab initialTab; 28 32 29 33 @override 30 34 Widget build(BuildContext context) { ··· 34 38 accountDid: accountDid, 35 39 postActionRepository: context.read<PostActionRepository>(), 36 40 )..loadSavedPosts(), 37 - child: const _SavedPostsContent(), 41 + child: _SavedPostsContent(initialTab: initialTab), 38 42 ); 39 43 } 40 44 } 41 45 42 46 class _SavedPostsContent extends StatefulWidget { 43 - const _SavedPostsContent(); 47 + const _SavedPostsContent({required this.initialTab}); 48 + 49 + final SavedPostsInitialTab initialTab; 44 50 45 51 @override 46 52 State<_SavedPostsContent> createState() => _SavedPostsContentState(); ··· 52 58 @override 53 59 void initState() { 54 60 super.initState(); 55 - _tabController = TabController(length: 2, vsync: this); 61 + _tabController = TabController(length: 3, vsync: this, initialIndex: widget.initialTab.index); 56 62 } 57 63 58 64 @override ··· 65 71 Widget build(BuildContext context) { 66 72 return Scaffold( 67 73 appBar: AppBar( 68 - title: const Text('Saved Posts'), 69 - actions: [ 70 - BlocBuilder<SavedPostsCubit, SavedPostsState>( 71 - builder: (context, state) { 72 - if (state.savedPosts.isEmpty) return const SizedBox.shrink(); 73 - return IconButton( 74 - icon: const Icon(Icons.delete_sweep_outlined), 75 - onPressed: () => _confirmClearAll(context), 76 - tooltip: 'Clear all saved posts', 77 - ); 78 - }, 79 - ), 80 - ], 74 + title: const Text('Bookmarks & Likes'), 81 75 bottom: TabBar( 82 76 controller: _tabController, 83 77 tabs: const [ 84 - Tab(text: 'All Saved'), 78 + Tab(text: 'Bookmarks'), 79 + Tab(text: 'Liked'), 85 80 Tab(text: 'Search'), 86 81 ], 87 82 ), 88 83 ), 89 - body: TabBarView(controller: _tabController, children: const [_AllSavedTab(), SemanticSearchTab()]), 90 - ); 91 - } 92 - 93 - void _confirmClearAll(BuildContext context) { 94 - showDialog<void>( 95 - context: context, 96 - builder: (context) => AlertDialog( 97 - title: const Text('Clear All Saved Posts?'), 98 - content: const Text('This will remove all your saved posts. This action cannot be undone.'), 99 - actions: [ 100 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 101 - FilledButton( 102 - onPressed: () { 103 - Navigator.pop(context); 104 - context.read<SavedPostsCubit>().clearAllSaved(); 105 - }, 106 - style: FilledButton.styleFrom( 107 - backgroundColor: context.colorScheme.error, 108 - foregroundColor: context.colorScheme.onError, 109 - ), 110 - child: const Text('Clear All'), 111 - ), 112 - ], 84 + body: TabBarView( 85 + controller: _tabController, 86 + children: const [_AllSavedTab(), _LikedPostsTab(), SemanticSearchTab()], 113 87 ), 114 88 ); 115 89 } ··· 123 97 } 124 98 125 99 class _AllSavedTabState extends State<_AllSavedTab> { 126 - final Set<String> _seenPostUris = <String>{}; 100 + final Set<String> _seenLocalPostUris = <String>{}; 101 + final Set<String> _seenCloudPostUris = <String>{}; 127 102 128 103 @override 129 104 Widget build(BuildContext context) { ··· 135 110 136 111 if (state.status == SavedPostsStatus.error) { 137 112 return ErrorState( 138 - title: 'Failed to load saved posts', 113 + title: 'Failed to load bookmarks', 139 114 message: state.error ?? 'Unknown error', 140 115 onRetry: () => context.read<SavedPostsCubit>().loadSavedPosts(), 141 116 ); 142 117 } 143 118 144 - if (state.savedPosts.isEmpty) { 119 + final localPosts = state.savedPosts 120 + .where((p) => p.saveType == 'local' || p.saveType == 'both') 121 + .toList(growable: false); 122 + final cloudPosts = state.savedPosts 123 + .where((p) => p.saveType == 'cloud' || p.saveType == 'both') 124 + .toList(growable: false); 125 + 126 + if (localPosts.isEmpty && cloudPosts.isEmpty) { 145 127 return const EmptyState( 146 - message: 'No saved posts', 147 - subtitle: 'Posts you save will appear here', 128 + message: 'No bookmarks', 129 + subtitle: 'Posts you bookmark will appear here', 148 130 icon: Icons.bookmark_outline, 149 131 ); 150 132 } 151 133 152 - return AnimatedRefreshIndicator( 153 - onRefresh: () => context.read<SavedPostsCubit>().loadSavedPosts(), 134 + return DefaultTabController( 135 + length: 2, 136 + child: Column( 137 + children: [ 138 + Padding( 139 + padding: const EdgeInsets.fromLTRB(12, 8, 8, 0), 140 + child: Row( 141 + children: [ 142 + const Expanded( 143 + child: TabBar( 144 + isScrollable: true, 145 + tabAlignment: TabAlignment.start, 146 + tabs: [ 147 + Tab(text: 'Local'), 148 + Tab(text: 'Bluesky'), 149 + ], 150 + ), 151 + ), 152 + if (localPosts.isNotEmpty) 153 + PopupMenuButton<_BookmarksMenuAction>( 154 + tooltip: 'Bookmark actions', 155 + onSelected: (action) { 156 + if (action == _BookmarksMenuAction.clearLocal) { 157 + _confirmClearLocal(context); 158 + } 159 + }, 160 + itemBuilder: (context) => const [ 161 + PopupMenuItem( 162 + value: _BookmarksMenuAction.clearLocal, 163 + child: ListTile( 164 + leading: Icon(Icons.delete_sweep_outlined), 165 + title: Text('Clear local bookmarks'), 166 + contentPadding: EdgeInsets.zero, 167 + dense: true, 168 + ), 169 + ), 170 + ], 171 + ), 172 + ], 173 + ), 174 + ), 175 + Expanded( 176 + child: TabBarView( 177 + children: [ 178 + _buildBookmarksList( 179 + posts: localPosts, 180 + seenKeys: _seenLocalPostUris, 181 + onRefresh: () => context.read<SavedPostsCubit>().loadSavedPosts(), 182 + ), 183 + _buildBookmarksList( 184 + posts: cloudPosts, 185 + seenKeys: _seenCloudPostUris, 186 + onRefresh: () => context.read<SavedPostsCubit>().loadSavedPosts(), 187 + ), 188 + ], 189 + ), 190 + ), 191 + ], 192 + ), 193 + ); 194 + }, 195 + ); 196 + } 197 + 198 + Widget _buildBookmarksList({ 199 + required List<SavedPostEntry> posts, 200 + required Set<String> seenKeys, 201 + required Future<void> Function() onRefresh, 202 + }) { 203 + if (posts.isEmpty) { 204 + return AnimatedRefreshIndicator( 205 + onRefresh: onRefresh, 206 + child: ListView( 207 + children: const [ 208 + SizedBox(height: 80), 209 + EmptyState( 210 + message: 'No bookmarks in this source', 211 + subtitle: 'Try switching tabs or saving posts to this source', 212 + icon: Icons.bookmark_border, 213 + ), 214 + ], 215 + ), 216 + ); 217 + } 218 + 219 + return AnimatedRefreshIndicator( 220 + onRefresh: onRefresh, 221 + child: ListView.builder( 222 + itemCount: posts.length, 223 + itemBuilder: (context, index) { 224 + final savedPost = posts[index]; 225 + return StaggeredEntrance( 226 + itemKey: '${savedPost.postUri}-${savedPost.saveType}', 227 + index: index, 228 + seenKeys: seenKeys, 229 + child: _SavedPostCard( 230 + savedPost: savedPost, 231 + onUnsave: () => context.read<SavedPostsCubit>().unsavePostById(savedPost.id), 232 + ), 233 + ); 234 + }, 235 + ), 236 + ); 237 + } 238 + 239 + void _confirmClearLocal(BuildContext context) { 240 + showDialog<void>( 241 + context: context, 242 + builder: (dialogContext) => AlertDialog( 243 + title: const Text('Clear local bookmarks?'), 244 + content: const Text( 245 + 'This removes only local bookmarks from this device. Bluesky cloud bookmarks will not be deleted.', 246 + ), 247 + actions: [ 248 + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), 249 + FilledButton( 250 + onPressed: () { 251 + Navigator.pop(dialogContext); 252 + context.read<SavedPostsCubit>().clearLocalSaved(); 253 + }, 254 + style: FilledButton.styleFrom( 255 + backgroundColor: context.colorScheme.error, 256 + foregroundColor: context.colorScheme.onError, 257 + ), 258 + child: const Text('Clear Local'), 259 + ), 260 + ], 261 + ), 262 + ); 263 + } 264 + } 265 + 266 + enum _BookmarksMenuAction { clearLocal } 267 + 268 + class _LikedPostsTab extends StatefulWidget { 269 + const _LikedPostsTab(); 270 + 271 + @override 272 + State<_LikedPostsTab> createState() => _LikedPostsTabState(); 273 + } 274 + 275 + class _LikedPostsTabState extends State<_LikedPostsTab> { 276 + final Set<String> _seenPostUris = <String>{}; 277 + List<LikedPostEntry> _likedPosts = const []; 278 + bool _isLoading = true; 279 + bool _isSyncing = false; 280 + String? _error; 281 + LikedPostsRepository? _repository; 282 + 283 + @override 284 + void initState() { 285 + super.initState(); 286 + _initAndLoad(); 287 + } 288 + 289 + Future<void> _initAndLoad() async { 290 + try { 291 + _repository = context.read<LikedPostsRepository>(); 292 + } catch (_) { 293 + setState(() { 294 + _isLoading = false; 295 + _error = 'Liked posts are unavailable right now.'; 296 + }); 297 + return; 298 + } 299 + 300 + await _syncAndReload(initial: true); 301 + } 302 + 303 + Future<void> _syncAndReload({bool initial = false}) async { 304 + final repository = _repository; 305 + if (repository == null) { 306 + return; 307 + } 308 + final accountDid = context.read<String>(); 309 + 310 + if (mounted) { 311 + setState(() { 312 + _isLoading = initial; 313 + _isSyncing = !initial; 314 + _error = null; 315 + }); 316 + } 317 + 318 + try { 319 + await repository.syncLikes(accountDid); 320 + final likedPosts = await repository.getLikedPosts(accountDid, limit: 200); 321 + if (!mounted) { 322 + return; 323 + } 324 + setState(() { 325 + _likedPosts = likedPosts; 326 + _isLoading = false; 327 + _isSyncing = false; 328 + }); 329 + } catch (e) { 330 + if (!mounted) { 331 + return; 332 + } 333 + setState(() { 334 + _isLoading = false; 335 + _isSyncing = false; 336 + _error = 'Failed to load liked posts: $e'; 337 + }); 338 + } 339 + } 340 + 341 + Future<void> _removeLike(LikedPostEntry entry) async { 342 + final repository = _repository; 343 + if (repository == null) { 344 + return; 345 + } 346 + final accountDid = context.read<String>(); 347 + await repository.removeLike(accountDid, entry.postUri); 348 + await _syncAndReload(); 349 + } 350 + 351 + @override 352 + Widget build(BuildContext context) { 353 + if (_isLoading) { 354 + return const LoadingState(); 355 + } 356 + 357 + if (_error != null) { 358 + return ErrorState(title: 'Failed to load liked posts', message: _error!, onRetry: () => _syncAndReload()); 359 + } 360 + 361 + if (_likedPosts.isEmpty) { 362 + return AnimatedRefreshIndicator( 363 + onRefresh: _syncAndReload, 364 + child: ListView( 365 + children: const [ 366 + SizedBox(height: 80), 367 + EmptyState( 368 + message: 'No liked posts', 369 + subtitle: 'Posts you like will appear here after sync', 370 + icon: Icons.favorite_outline, 371 + ), 372 + ], 373 + ), 374 + ); 375 + } 376 + 377 + return Stack( 378 + children: [ 379 + AnimatedRefreshIndicator( 380 + onRefresh: _syncAndReload, 154 381 child: ListView.builder( 155 - itemCount: state.savedPosts.length, 382 + itemCount: _likedPosts.length, 156 383 itemBuilder: (context, index) { 157 - final savedPost = state.savedPosts[index]; 384 + final likedPost = _likedPosts[index]; 158 385 return StaggeredEntrance( 159 - itemKey: savedPost.postUri, 386 + itemKey: likedPost.postUri, 160 387 index: index, 161 388 seenKeys: _seenPostUris, 162 - child: _SavedPostCard( 163 - savedPost: savedPost, 164 - onUnsave: () => context.read<SavedPostsCubit>().unsavePostById(savedPost.id), 165 - ), 389 + child: _LikedPostCard(likedPost: likedPost, onRemove: () => _removeLike(likedPost)), 166 390 ); 167 391 }, 168 392 ), 169 - ); 170 - }, 393 + ), 394 + if (_isSyncing) 395 + const Align( 396 + alignment: Alignment.topCenter, 397 + child: Padding( 398 + padding: EdgeInsets.only(top: 8), 399 + child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)), 400 + ), 401 + ), 402 + ], 171 403 ); 172 404 } 173 405 } ··· 181 413 FeedViewPost? _deserializePost() { 182 414 try { 183 415 final json = jsonDecode(savedPost.postJson) as Map<String, dynamic>; 416 + if (json.containsKey('post')) { 417 + return FeedViewPost.fromJson(json); 418 + } 184 419 return FeedViewPost(post: PostView.fromJson(json)); 185 420 } catch (e) { 186 421 log.e('Failed to deserialize saved post', error: e); ··· 214 449 margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 215 450 child: ListTile( 216 451 leading: const Icon(Icons.bookmark), 217 - title: const Text('Saved Post'), 452 + title: const Text('Bookmarked Post'), 218 453 subtitle: Text('Saved on ${_formatDate(savedPost.savedAt)}', style: context.textTheme.bodySmall), 219 454 trailing: Row( 220 455 mainAxisSize: MainAxisSize.min, ··· 239 474 ); 240 475 } 241 476 242 - String _formatDate(DateTime date) { 243 - final now = DateTime.now(); 244 - final difference = now.difference(date); 245 - 246 - if (difference.inMinutes < 1) return 'just now'; 247 - if (difference.inHours < 1) return '${difference.inMinutes}m ago'; 248 - if (difference.inDays < 1) return '${difference.inHours}h ago'; 249 - if (difference.inDays < 7) return '${difference.inDays}d ago'; 250 - return '${date.month}/${date.day}/${date.year}'; 251 - } 252 - 253 477 String _resolveAppViewProvider(BuildContext context) { 254 478 try { 255 479 return context.read<SettingsCubit>().state.appViewProvider; ··· 258 482 } 259 483 } 260 484 } 485 + 486 + class _LikedPostCard extends StatelessWidget { 487 + const _LikedPostCard({required this.likedPost, required this.onRemove}); 488 + 489 + final LikedPostEntry likedPost; 490 + final VoidCallback onRemove; 491 + 492 + FeedViewPost? _deserializePost() { 493 + try { 494 + final json = jsonDecode(likedPost.postJson) as Map<String, dynamic>; 495 + if (json.containsKey('post')) { 496 + return FeedViewPost.fromJson(json); 497 + } 498 + return FeedViewPost(post: PostView.fromJson(json)); 499 + } catch (e) { 500 + log.e('Failed to deserialize liked post', error: e); 501 + return null; 502 + } 503 + } 504 + 505 + @override 506 + Widget build(BuildContext context) { 507 + final feedViewPost = _deserializePost(); 508 + final accountDid = context.read<String>(); 509 + 510 + return Dismissible( 511 + key: ValueKey('liked-${likedPost.id}'), 512 + direction: DismissDirection.endToStart, 513 + background: Container( 514 + alignment: Alignment.centerRight, 515 + padding: const EdgeInsets.only(right: 16), 516 + color: context.colorScheme.error, 517 + child: Icon(Icons.favorite_border, color: context.colorScheme.onError), 518 + ), 519 + onDismissed: (_) => onRemove(), 520 + child: feedViewPost != null 521 + ? PostCardWithActions(feedViewPost: feedViewPost, accountDid: accountDid) 522 + : _buildFallback(context), 523 + ); 524 + } 525 + 526 + Widget _buildFallback(BuildContext context) { 527 + return Card( 528 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 529 + child: ListTile( 530 + leading: const Icon(Icons.favorite_outline), 531 + title: const Text('Liked Post'), 532 + subtitle: Text('Liked on ${_formatDate(likedPost.likedAt)}', style: context.textTheme.bodySmall), 533 + trailing: Row( 534 + mainAxisSize: MainAxisSize.min, 535 + children: [ 536 + IconButton( 537 + icon: const Icon(Icons.open_in_new), 538 + onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(likedPost.postUri)}'), 539 + tooltip: 'Open post', 540 + ), 541 + IconButton(icon: const Icon(Icons.delete_outline), onPressed: onRemove, tooltip: 'Remove'), 542 + ], 543 + ), 544 + ), 545 + ); 546 + } 547 + } 548 + 549 + String _formatDate(DateTime date) { 550 + final now = DateTime.now(); 551 + final difference = now.difference(date); 552 + 553 + if (difference.inMinutes < 1) return 'just now'; 554 + if (difference.inHours < 1) return '${difference.inMinutes}m ago'; 555 + if (difference.inDays < 1) return '${difference.inHours}h ago'; 556 + if (difference.inDays < 7) return '${difference.inDays}d ago'; 557 + return '${date.month}/${date.day}/${date.year}'; 558 + }
+50
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 106 106 const SizedBox(height: 10), 107 107 ModerationBadgeRow(ui: postUi), 108 108 ], 109 + if (record?.reply != null) ...[ 110 + const SizedBox(height: AppSpacing.xs), 111 + _buildReplyContext(context), 112 + ], 109 113 if (bodyText.isNotEmpty) ...[ 110 114 const SizedBox(height: AppSpacing.xs), 111 115 if (primaryImageUrl == null && contentEmbed == null) ··· 226 230 } catch (_) { 227 231 return null; 228 232 } 233 + } 234 + 235 + Widget _buildReplyContext(BuildContext context) { 236 + final parentPost = feedViewPost.reply?.parent.isPostView == true ? feedViewPost.reply!.parent.postView : null; 237 + if (parentPost == null) { 238 + return Row( 239 + children: [ 240 + Icon(Icons.reply, size: 12, color: context.colorScheme.onSurfaceVariant), 241 + const SizedBox(width: 6), 242 + Expanded( 243 + child: Text( 244 + 'Reply in a thread', 245 + maxLines: 1, 246 + overflow: TextOverflow.ellipsis, 247 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 248 + ), 249 + ), 250 + ], 251 + ); 252 + } 253 + 254 + final parentRecord = _tryParseRecord(parentPost.record); 255 + final parentText = parentRecord?.text.trim() ?? ''; 256 + return Container( 257 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), 258 + decoration: BoxDecoration( 259 + borderRadius: BorderRadius.circular(8), 260 + border: Border.all(color: context.colorScheme.outlineVariant), 261 + color: context.colorScheme.surfaceContainerLow, 262 + ), 263 + child: Column( 264 + crossAxisAlignment: CrossAxisAlignment.start, 265 + children: [ 266 + Text( 267 + 'Replying to @${parentPost.author.handle}', 268 + maxLines: 1, 269 + overflow: TextOverflow.ellipsis, 270 + style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 271 + ), 272 + if (parentText.isNotEmpty) ...[ 273 + const SizedBox(height: 2), 274 + Text(parentText, maxLines: 2, overflow: TextOverflow.ellipsis, style: context.textTheme.bodySmall), 275 + ], 276 + ], 277 + ), 278 + ); 229 279 } 230 280 }
+45 -13
lib/features/feed/presentation/widgets/post_card.dart
··· 66 66 children: [ 67 67 _buildHeader(context, post.author), 68 68 if (postUi.alert || postUi.inform) ...[const SizedBox(height: 10), ModerationBadgeRow(ui: postUi)], 69 - if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 69 + if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyContext(context)], 70 70 if (record != null && record.text.isNotEmpty) ...[ 71 71 const SizedBox(height: 12), 72 72 FacetText(text: record.text, facets: record.facets, style: feedPostBodyTextStyle(context)), ··· 125 125 ); 126 126 } 127 127 128 - Widget _buildReplyLabel(BuildContext context) { 129 - return Row( 130 - children: [ 131 - Icon(Icons.reply, size: 14, color: context.colorScheme.onSurfaceVariant), 132 - const SizedBox(width: 6), 133 - Flexible( 134 - child: Text( 135 - 'Reply in a thread', 136 - maxLines: 1, 137 - overflow: TextOverflow.ellipsis, 128 + Widget _buildReplyContext(BuildContext context) { 129 + final parentPost = feedViewPost.reply?.parent.isPostView == true ? feedViewPost.reply!.parent.postView : null; 130 + if (parentPost == null) { 131 + return Row( 132 + children: [ 133 + Icon(Icons.reply, size: 14, color: context.colorScheme.onSurfaceVariant), 134 + const SizedBox(width: 6), 135 + Flexible( 136 + child: Text( 137 + 'Reply in a thread', 138 + maxLines: 1, 139 + overflow: TextOverflow.ellipsis, 140 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 141 + ), 142 + ), 143 + ], 144 + ); 145 + } 146 + 147 + final parentRecord = _tryParseRecord(parentPost.record); 148 + final parentText = parentRecord?.text.trim() ?? ''; 149 + return Container( 150 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), 151 + decoration: BoxDecoration( 152 + borderRadius: BorderRadius.circular(8), 153 + border: Border.all(color: context.colorScheme.outlineVariant), 154 + color: context.colorScheme.surfaceContainerLow, 155 + ), 156 + child: Column( 157 + crossAxisAlignment: CrossAxisAlignment.start, 158 + children: [ 159 + Text( 160 + 'Replying to @${parentPost.author.handle}', 138 161 style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 139 162 ), 140 - ), 141 - ], 163 + if (parentText.isNotEmpty) ...[ 164 + const SizedBox(height: 4), 165 + Text( 166 + parentText, 167 + maxLines: 2, 168 + overflow: TextOverflow.ellipsis, 169 + style: context.textTheme.bodySmall, 170 + ), 171 + ], 172 + ], 173 + ), 142 174 ); 143 175 } 144 176
+21
lib/features/profile/data/profile_repository.dart
··· 3 3 4 4 import 'package:atproto_core/atproto_core.dart' as atp_core; 5 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 6 7 import 'package:bluesky/bluesky.dart'; 7 8 import 'package:lazurite/core/database/app_database.dart'; 8 9 import 'package:lazurite/core/logging/app_logger.dart'; ··· 101 102 return suggestions.where((p) => !moderationService.shouldFilterProfileInList(p)).toList(); 102 103 } 103 104 105 + Future<ProfileActorLikesResult> getActorLikes({required String actor, String? cursor, int limit = 50}) async { 106 + final headers = _appViewContext.appBskyHeadersForEndpoint( 107 + 'app.bsky.feed.getActorLikes', 108 + await _moderationService?.headersForRequest(), 109 + ); 110 + final response = await _bluesky.feed.getActorLikes(actor: actor, cursor: cursor, limit: limit, $headers: headers); 111 + final moderationService = _moderationService; 112 + final posts = moderationService == null 113 + ? response.data.feed 114 + : response.data.feed.where((post) => !moderationService.shouldFilterFeedViewPostInList(post)).toList(); 115 + return ProfileActorLikesResult(posts: posts, cursor: response.data.cursor); 116 + } 117 + 104 118 Future<ProfileViewDetailed?> getCurrentUserProfile(AuthTokens tokens) async { 105 119 log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); 106 120 ··· 190 204 return null; 191 205 } 192 206 } 207 + 208 + class ProfileActorLikesResult { 209 + const ProfileActorLikesResult({required this.posts, this.cursor}); 210 + 211 + final List<FeedViewPost> posts; 212 + final String? cursor; 213 + }
+497 -50
lib/features/profile/presentation/profile_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 3 import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; 3 4 import 'package:bluesky/moderation.dart' as bsky_moderation; 4 5 import 'package:flutter/material.dart'; ··· 51 52 import 'package:lazurite/shared/utils/format_utils.dart'; 52 53 import 'package:url_launcher/url_launcher.dart'; 53 54 55 + enum _ProfileFeedSlice { posts, replies, quotes, reposts, media } 56 + 57 + class _ProfileFeedTabConfig { 58 + const _ProfileFeedTabConfig({ 59 + required this.label, 60 + required this.requestFilter, 61 + required this.slice, 62 + required this.emptyLabel, 63 + }); 64 + 65 + final String label; 66 + final FeedFilter requestFilter; 67 + final _ProfileFeedSlice slice; 68 + final String emptyLabel; 69 + } 70 + 54 71 class ProfileScreen extends StatefulWidget { 55 72 const ProfileScreen({super.key, this.actor, this.showBackButton = false}); 56 73 ··· 63 80 64 81 class _ProfileScreenState extends State<ProfileScreen> with TickerProviderStateMixin { 65 82 static const _feedTabs = [ 66 - (label: 'Posts', filter: FeedFilter.postsNoReplies), 67 - (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), 68 - (label: 'Media', filter: FeedFilter.postsWithMedia), 83 + _ProfileFeedTabConfig( 84 + label: 'Posts', 85 + requestFilter: FeedFilter.postsNoReplies, 86 + slice: _ProfileFeedSlice.posts, 87 + emptyLabel: 'No posts yet', 88 + ), 89 + _ProfileFeedTabConfig( 90 + label: 'Replies', 91 + requestFilter: FeedFilter.postsWithReplies, 92 + slice: _ProfileFeedSlice.replies, 93 + emptyLabel: 'No replies yet', 94 + ), 95 + _ProfileFeedTabConfig( 96 + label: 'Quotes', 97 + requestFilter: FeedFilter.postsWithReplies, 98 + slice: _ProfileFeedSlice.quotes, 99 + emptyLabel: 'No quotes yet', 100 + ), 101 + _ProfileFeedTabConfig( 102 + label: 'Reposts', 103 + requestFilter: FeedFilter.postsWithReplies, 104 + slice: _ProfileFeedSlice.reposts, 105 + emptyLabel: 'No reposts yet', 106 + ), 107 + _ProfileFeedTabConfig( 108 + label: 'Media', 109 + requestFilter: FeedFilter.postsWithMedia, 110 + slice: _ProfileFeedSlice.media, 111 + emptyLabel: 'No media posts yet', 112 + ), 69 113 ]; 70 114 71 - static const _baseTabLabels = ['POSTS', 'REPLIES', 'MEDIA', 'LISTS', 'STARTER PACKS']; 115 + static const _baseTabLabelsOwn = ['POSTS', 'REPLIES', 'QUOTES', 'REPOSTS', 'MEDIA', 'LISTS', 'STARTER PACKS']; 116 + static const _baseTabLabelsOther = [ 117 + 'POSTS', 118 + 'REPLIES', 119 + 'QUOTES', 120 + 'REPOSTS', 121 + 'MEDIA', 122 + 'LIKED', 123 + 'LISTS', 124 + 'STARTER PACKS', 125 + ]; 72 126 static const _suggestedTabLabel = 'SUGGESTED'; 73 127 static const _coverRefreshTriggerDistance = 72.0; 74 128 ··· 82 136 bool _headerRefreshInFlight = false; 83 137 String? _lastScheduledProfileActorLoad; 84 138 String? _lastScheduledFeedLoadKey; 139 + String? _cachedFeedActor; 140 + final Map<FeedFilter, FeedState> _cachedFeedStates = {}; 85 141 86 142 bool get _isCurrentRoute => ModalRoute.of(context)?.isCurrent ?? true; 87 143 ··· 98 154 super.didUpdateWidget(oldWidget); 99 155 if (oldWidget.actor != widget.actor) { 100 156 _tabController.index = 0; 157 + _cachedFeedActor = null; 158 + _cachedFeedStates.clear(); 101 159 _setSuggestedTabVisibility(false); 102 160 _loadProfileAndFeed(); 103 161 } ··· 113 171 void _loadProfileAndFeed({FeedFilter? filter}) { 114 172 final actor = _resolvedActor; 115 173 if (actor == null) return; 174 + _resetFeedCacheIfActorChanged(actor); 116 175 context.read<ProfileBloc>().add(ProfileLoadRequested(actor: actor)); 117 - context.read<FeedBloc>().add(FeedLoadRequested(actor: actor, filter: filter ?? _currentFilter)); 176 + context.read<FeedBloc>().add(FeedLoadRequested(actor: actor, filter: filter ?? _currentRequestFilter)); 177 + } 178 + 179 + void _loadFeedOnly({required FeedFilter filter}) { 180 + final actor = _resolvedActor; 181 + if (actor == null) return; 182 + _resetFeedCacheIfActorChanged(actor); 183 + final cached = _cachedFeedStates[filter]; 184 + if (cached != null && _feedMatchesExpectedActor(cached, actor, context.read<ProfileBloc>().state.profile)) { 185 + if (cached.status == FeedStatus.loading || cached.status == FeedStatus.loaded) { 186 + return; 187 + } 188 + } 189 + context.read<FeedBloc>().add(FeedLoadRequested(actor: actor, filter: filter)); 118 190 } 119 191 120 192 String? get _resolvedActor { ··· 196 268 } 197 269 198 270 void _scheduleFeedLoadIfNeeded(String actor, FeedFilter filter, FeedState feedState, ProfileViewDetailed? profile) { 271 + final cachedState = _cachedFeedStates[filter]; 272 + if (cachedState != null && _feedMatchesExpectedActor(cachedState, actor, profile)) { 273 + if (cachedState.status == FeedStatus.loading || cachedState.status == FeedStatus.loaded) { 274 + _lastScheduledFeedLoadKey = null; 275 + return; 276 + } 277 + } 278 + 199 279 if (feedState.status == FeedStatus.loading && 200 280 feedState.filter == filter && 201 281 _feedMatchesExpectedActor(feedState, actor, profile)) { ··· 223 303 }); 224 304 } 225 305 306 + List<String> get _baseTabLabels => _showSuggestedTab ? _baseTabLabelsOther : _baseTabLabelsOwn; 307 + 226 308 List<String> get _tabLabels => 227 309 _showSuggestedTab ? [..._baseTabLabels, _suggestedTabLabel] : List<String>.of(_baseTabLabels); 228 310 229 - FeedFilter get _currentFilter => _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0].filter; 311 + _ProfileFeedTabConfig get _currentFeedTab => 312 + _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0]; 313 + 314 + FeedFilter get _currentRequestFilter => _currentFeedTab.requestFilter; 315 + 316 + _ProfileFeedSlice get _currentFeedSlice => _currentFeedTab.slice; 317 + 318 + void _resetFeedCacheIfActorChanged(String actor) { 319 + if (_cachedFeedActor == actor) { 320 + return; 321 + } 322 + _cachedFeedActor = actor; 323 + _cachedFeedStates.clear(); 324 + } 325 + 326 + FeedState _cachedStateForFilter( 327 + FeedFilter filter, { 328 + required String? expectedActor, 329 + required ProfileViewDetailed? profile, 330 + }) { 331 + final cached = _cachedFeedStates[filter]; 332 + if (cached == null) { 333 + return const FeedState.initial(); 334 + } 335 + 336 + if (expectedActor == null || _feedMatchesExpectedActor(cached, expectedActor, profile)) { 337 + return cached; 338 + } 339 + return const FeedState.initial(); 340 + } 230 341 231 342 bool _shouldShowSuggestedTab(ProfileViewDetailed? profile) { 232 343 if (profile == null) return false; ··· 242 353 return; 243 354 } 244 355 245 - final maxIndex = show ? _baseTabLabels.length : _baseTabLabels.length - 1; 356 + final maxIndex = show ? _baseTabLabelsOther.length : _baseTabLabelsOwn.length - 1; 246 357 final nextIndex = _tabController.index.clamp(0, maxIndex); 247 358 final previousController = _tabController; 248 359 _showSuggestedTab = show; ··· 337 448 builder: (context, profileState) { 338 449 return BlocBuilder<FeedBloc, FeedState>( 339 450 builder: (context, feedState) { 340 - final profile = profileState.profile; 341 451 final expectedActor = _resolvedActor; 452 + final profile = profileState.profile; 342 453 final profileMatchesExpectedActor = expectedActor == null 343 454 ? true 344 455 : _profileMatchesExpectedActor(profile, expectedActor); 456 + final actorScopedProfile = profileMatchesExpectedActor ? profile : null; 457 + if (expectedActor != null) { 458 + _resetFeedCacheIfActorChanged(expectedActor); 459 + } 460 + if (expectedActor == null || 461 + _feedMatchesExpectedActor(feedState, expectedActor, actorScopedProfile)) { 462 + _cachedFeedStates[feedState.filter] = feedState; 463 + } 345 464 if (expectedActor != null && _isCurrentRoute) { 346 465 _scheduleProfileLoadIfNeeded(expectedActor, profileState); 347 - _scheduleFeedLoadIfNeeded(expectedActor, _currentFilter, feedState, profile); 466 + if (_tabController.index < _feedTabs.length) { 467 + _scheduleFeedLoadIfNeeded(expectedActor, _currentRequestFilter, feedState, actorScopedProfile); 468 + } 348 469 } 349 470 350 471 final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 351 - final isOwnProfile = profile?.did == currentUserDid; 472 + final isOwnProfile = actorScopedProfile?.did == currentUserDid; 473 + final feedTabChildren = _feedTabs.map((tab) { 474 + final stateForTab = _cachedStateForFilter( 475 + tab.requestFilter, 476 + expectedActor: expectedActor, 477 + profile: actorScopedProfile, 478 + ); 479 + return KeyedSubtree( 480 + key: PageStorageKey<String>('profile-feed-tab-${tab.slice.name}'), 481 + child: _buildFeedList( 482 + sourceState: stateForTab, 483 + requestFilter: tab.requestFilter, 484 + slice: tab.slice, 485 + emptyLabel: tab.emptyLabel, 486 + profile: actorScopedProfile, 487 + expectedActor: expectedActor, 488 + ), 489 + ); 490 + }); 352 491 final tabChildren = <Widget>[ 353 - ..._feedTabs.map((t) => _buildFeedList(feedState, t.filter, profile, expectedActor)), 354 - _buildListsTab(context, profile), 355 - _buildStarterPacksTab(context, profile), 356 - if (_showSuggestedTab) _buildSuggestedFollowsTab(profile), 492 + ...feedTabChildren, 493 + if (_showSuggestedTab) 494 + KeyedSubtree( 495 + key: const PageStorageKey<String>('profile-liked-tab'), 496 + child: _buildLikedPostsTab(context, actorScopedProfile), 497 + ), 498 + KeyedSubtree( 499 + key: const PageStorageKey<String>('profile-lists-tab'), 500 + child: _buildListsTab(context, actorScopedProfile), 501 + ), 502 + KeyedSubtree( 503 + key: const PageStorageKey<String>('profile-starter-packs-tab'), 504 + child: _buildStarterPacksTab(context, actorScopedProfile), 505 + ), 506 + if (_showSuggestedTab) 507 + KeyedSubtree( 508 + key: const PageStorageKey<String>('profile-suggested-tab'), 509 + child: _buildSuggestedFollowsTab(actorScopedProfile), 510 + ), 357 511 ]; 358 512 359 513 return NotificationListener<ScrollUpdateNotification>( ··· 381 535 floating: true, 382 536 pinned: true, 383 537 snap: true, 384 - title: Text(_appBarTitle(profile)), 538 + title: Text(_appBarTitle(actorScopedProfile)), 385 539 leading: widget.showBackButton 386 540 ? IconButton( 387 541 icon: const Icon(Icons.arrow_back), ··· 389 543 ) 390 544 : const AppShellMenuButton(), 391 545 actions: [ 392 - if (profile != null && isOwnProfile) 546 + if (actorScopedProfile != null && isOwnProfile) 393 547 IconButton( 394 548 key: const Key('profile_more_button'), 395 549 icon: const Icon(Icons.more_vert), 396 - onPressed: () => _showOwnProfileMoreOptions(context, profile), 550 + onPressed: () => _showOwnProfileMoreOptions(context, actorScopedProfile), 397 551 ), 398 552 IconButton( 399 553 icon: const Icon(Icons.settings_outlined), ··· 401 555 ), 402 556 ], 403 557 ), 404 - SliverToBoxAdapter(child: _buildCoverSection(context, profile)), 558 + SliverToBoxAdapter(child: _buildCoverSection(context, actorScopedProfile)), 405 559 SliverToBoxAdapter( 406 560 child: _buildProfileHeaderRefreshZone( 407 561 key: const ValueKey('profile_header_details_refresh_zone'), ··· 415 569 padding: AppInsets.allLg, 416 570 child: Center(child: CircularProgressIndicator()), 417 571 ), 418 - _ => _buildProfileSummary(context, profile, isOwnProfile), 572 + _ => _buildProfileSummary(context, actorScopedProfile, isOwnProfile), 419 573 }, 420 574 ), 421 575 ), ··· 427 581 tabs: [for (final label in _tabLabels) Tab(text: label)], 428 582 onTap: (index) { 429 583 if (index < _feedTabs.length) { 430 - _loadProfileAndFeed(filter: _feedTabs[index].filter); 584 + _loadFeedOnly(filter: _feedTabs[index].requestFilter); 431 585 } 432 586 }, 433 587 isScrollable: true, ··· 634 788 ), 635 789 const SizedBox(height: 16), 636 790 if (isOwnProfile) 637 - OutlinedButton.icon( 638 - onPressed: () => context.push('/saved'), 639 - icon: const Icon(Icons.bookmark_outline), 640 - label: const Text('Saved Posts'), 791 + Wrap( 792 + spacing: 8, 793 + runSpacing: 8, 794 + children: [ 795 + OutlinedButton.icon( 796 + onPressed: () => context.push('/bookmarks'), 797 + icon: const Icon(Icons.bookmark_outline), 798 + label: const Text('Bookmarks'), 799 + ), 800 + OutlinedButton.icon( 801 + onPressed: () => context.push('/liked'), 802 + icon: const Icon(Icons.favorite_outline), 803 + label: const Text('Liked'), 804 + ), 805 + ], 641 806 ), 642 807 if (!isOwnProfile) _buildProfileActions(context, profile), 643 808 const SizedBox(height: 16), ··· 932 1097 ); 933 1098 } 934 1099 935 - Widget _buildFeedList( 936 - FeedState feedState, 937 - FeedFilter tabFilter, 938 - ProfileViewDetailed? profile, 939 - String? expectedActor, 940 - ) { 941 - final isActiveTab = tabFilter == _currentFilter; 1100 + Widget _buildFeedList({ 1101 + required FeedState sourceState, 1102 + required FeedFilter requestFilter, 1103 + required _ProfileFeedSlice slice, 1104 + required String emptyLabel, 1105 + required ProfileViewDetailed? profile, 1106 + required String? expectedActor, 1107 + }) { 1108 + final isActiveTab = _currentFeedSlice == slice; 942 1109 final feedMatchesExpectedActor = expectedActor == null 943 1110 ? true 944 - : _feedMatchesExpectedActor(feedState, expectedActor, profile); 1111 + : _feedMatchesExpectedActor(sourceState, expectedActor, profile); 1112 + final visiblePosts = _filterPostsForSlice(sourceState.posts, slice); 1113 + final visibleFeedState = sourceState.copyWith(posts: visiblePosts); 945 1114 946 1115 if (expectedActor != null && isActiveTab && !feedMatchesExpectedActor) { 947 1116 return const Center(child: CircularProgressIndicator()); 948 1117 } 949 1118 950 - if (isActiveTab && feedState.status == FeedStatus.initial) { 1119 + if (isActiveTab && sourceState.status == FeedStatus.initial) { 951 1120 return const Center(child: CircularProgressIndicator()); 952 1121 } 953 1122 954 - if (feedState.isLoading && feedState.filter == tabFilter) { 1123 + if (sourceState.isLoading && visiblePosts.isEmpty) { 955 1124 return const Center(child: CircularProgressIndicator()); 956 1125 } 957 1126 958 - if (feedState.hasError && feedState.filter == tabFilter && feedMatchesExpectedActor) { 1127 + if (sourceState.hasError && feedMatchesExpectedActor) { 959 1128 return Center( 960 1129 child: Column( 961 1130 mainAxisSize: MainAxisSize.min, 962 1131 children: [ 963 - Text(feedState.errorMessage ?? 'Failed to load posts'), 1132 + Text(sourceState.errorMessage ?? 'Failed to load posts'), 964 1133 const SizedBox(height: 12), 965 1134 FilledButton( 966 - onPressed: () => _loadProfileAndFeed(filter: tabFilter), 1135 + onPressed: () => _loadFeedOnly(filter: requestFilter), 967 1136 child: const Text('Retry'), 968 1137 ), 969 1138 ], ··· 971 1140 ); 972 1141 } 973 1142 974 - if (feedState.filter != tabFilter) { 975 - return const SizedBox.shrink(); 1143 + if (visiblePosts.isEmpty) { 1144 + return Center(child: Text(emptyLabel)); 976 1145 } 977 1146 978 - if (!feedState.hasPosts) { 979 - return Center(child: Text(tabFilter.emptyLabel)); 1147 + if (slice == _ProfileFeedSlice.replies) { 1148 + return _buildRepliesFeed(context, visibleFeedState, requestFilter: requestFilter); 980 1149 } 981 1150 982 1151 return BlocBuilder<SettingsCubit, SettingsState>( 983 1152 buildWhen: (prev, curr) => prev.feedLayout != curr.feedLayout, 984 1153 builder: (context, settingsState) { 985 1154 if (settingsState.feedLayout == FeedLayout.card) { 986 - return _buildGridFeed(context, feedState); 1155 + return _buildGridFeed(context, visibleFeedState, requestFilter: requestFilter, slice: slice); 987 1156 } 988 - return _buildLinearFeed(context, feedState); 1157 + return _buildLinearFeed(context, visibleFeedState, requestFilter: requestFilter, slice: slice); 989 1158 }, 990 1159 ); 991 1160 } 992 1161 993 - Widget _buildGridFeed(BuildContext context, FeedState feedState) { 994 - final accountDid = _resolvedActor ?? ''; 1162 + Widget _buildRepliesFeed(BuildContext context, FeedState feedState, {required FeedFilter requestFilter}) { 1163 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 1164 + return RefreshIndicator( 1165 + onRefresh: _refresh, 1166 + child: NotificationListener<ScrollNotification>( 1167 + onNotification: (notification) { 1168 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 1169 + feedState.hasMore && 1170 + !feedState.isLoadingMore && 1171 + _currentRequestFilter == requestFilter) { 1172 + context.read<FeedBloc>().add(const FeedLoadMoreRequested()); 1173 + } 1174 + return false; 1175 + }, 1176 + child: ListView.builder( 1177 + key: const PageStorageKey<String>('profile_replies_thread_list'), 1178 + padding: EdgeInsets.zero, 1179 + itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 1180 + itemBuilder: (context, index) { 1181 + if (index >= feedState.posts.length) { 1182 + return const Padding( 1183 + padding: EdgeInsets.all(16), 1184 + child: Center(child: CircularProgressIndicator()), 1185 + ); 1186 + } 1187 + return _ProfileReplyThreadItem(feedViewPost: feedState.posts[index], accountDid: accountDid); 1188 + }, 1189 + ), 1190 + ), 1191 + ); 1192 + } 1193 + 1194 + List<FeedViewPost> _filterPostsForSlice(List<FeedViewPost> posts, _ProfileFeedSlice slice) { 1195 + switch (slice) { 1196 + case _ProfileFeedSlice.posts: 1197 + case _ProfileFeedSlice.media: 1198 + return posts; 1199 + case _ProfileFeedSlice.replies: 1200 + return posts.where((post) => !_isRepost(post) && post.reply != null).toList(growable: false); 1201 + case _ProfileFeedSlice.quotes: 1202 + return posts.where((post) => !_isRepost(post) && _isQuote(post)).toList(growable: false); 1203 + case _ProfileFeedSlice.reposts: 1204 + return posts.where(_isRepost).toList(growable: false); 1205 + } 1206 + } 1207 + 1208 + bool _isRepost(FeedViewPost post) => post.reason?.isReasonRepost ?? false; 1209 + 1210 + bool _isQuote(FeedViewPost post) { 1211 + final embed = post.post.embed; 1212 + if (embed == null) { 1213 + return false; 1214 + } 1215 + return embed.isEmbedRecordView || embed.isEmbedRecordWithMediaView; 1216 + } 1217 + 1218 + Widget _buildGridFeed( 1219 + BuildContext context, 1220 + FeedState feedState, { 1221 + required FeedFilter requestFilter, 1222 + required _ProfileFeedSlice slice, 1223 + }) { 1224 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 1225 + final scrollKey = slice == _ProfileFeedSlice.posts 1226 + ? const ValueKey('profile_grid_feed') 1227 + : PageStorageKey<String>('profile_grid_feed_${slice.name}'); 995 1228 996 1229 return RefreshIndicator( 997 1230 onRefresh: _refresh, ··· 999 1232 onNotification: (notification) { 1000 1233 if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 1001 1234 feedState.hasMore && 1002 - !feedState.isLoadingMore) { 1235 + !feedState.isLoadingMore && 1236 + _currentRequestFilter == requestFilter) { 1003 1237 context.read<FeedBloc>().add(const FeedLoadMoreRequested()); 1004 1238 } 1005 1239 return false; 1006 1240 }, 1007 1241 child: ListView.builder( 1008 - key: const ValueKey('profile_grid_feed'), 1242 + key: scrollKey, 1009 1243 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 1010 1244 itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 1011 1245 itemBuilder: (context, index) { ··· 1038 1272 ); 1039 1273 } 1040 1274 1041 - Widget _buildLinearFeed(BuildContext context, FeedState feedState) { 1042 - final accountDid = _resolvedActor ?? ''; 1275 + Widget _buildLinearFeed( 1276 + BuildContext context, 1277 + FeedState feedState, { 1278 + required FeedFilter requestFilter, 1279 + required _ProfileFeedSlice slice, 1280 + }) { 1281 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 1043 1282 return RefreshIndicator( 1044 1283 onRefresh: _refresh, 1045 1284 child: NotificationListener<ScrollNotification>( 1046 1285 onNotification: (notification) { 1047 1286 if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 1048 1287 feedState.hasMore && 1049 - !feedState.isLoadingMore) { 1288 + !feedState.isLoadingMore && 1289 + _currentRequestFilter == requestFilter) { 1050 1290 context.read<FeedBloc>().add(const FeedLoadMoreRequested()); 1051 1291 } 1052 1292 return false; 1053 1293 }, 1054 1294 child: ListView.builder( 1295 + key: PageStorageKey<String>('profile_linear_feed_${slice.name}'), 1055 1296 padding: EdgeInsets.zero, 1056 1297 itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 1057 1298 itemBuilder: (context, index) { ··· 1086 1327 return _ProfileListsPane(actor: actor, listRepository: listRepository); 1087 1328 } 1088 1329 1330 + Widget _buildLikedPostsTab(BuildContext context, ProfileViewDetailed? profile) { 1331 + final actor = profile?.did ?? _resolvedActor; 1332 + if (actor == null) return const SizedBox.shrink(); 1333 + 1334 + ProfileRepository? profileRepository; 1335 + try { 1336 + profileRepository = context.read<ProfileRepository>(); 1337 + } catch (_) { 1338 + return const SizedBox.shrink(); 1339 + } 1340 + 1341 + return _ProfileLikedPostsPane(actor: actor, profileRepository: profileRepository); 1342 + } 1343 + 1089 1344 Widget _buildStarterPacksTab(BuildContext context, ProfileViewDetailed? profile) { 1090 1345 final actor = profile?.did ?? _resolvedActor; 1091 1346 if (actor == null) return const SizedBox.shrink(); ··· 1107 1362 } 1108 1363 } 1109 1364 1365 + class _ProfileReplyThreadItem extends StatelessWidget { 1366 + const _ProfileReplyThreadItem({required this.feedViewPost, required this.accountDid}); 1367 + 1368 + final FeedViewPost feedViewPost; 1369 + final String accountDid; 1370 + 1371 + @override 1372 + Widget build(BuildContext context) { 1373 + final parent = feedViewPost.reply?.parent; 1374 + final hasParentPost = parent?.isPostView == true; 1375 + 1376 + if (!hasParentPost) { 1377 + return PostCardWithActions( 1378 + feedViewPost: feedViewPost, 1379 + accountDid: accountDid, 1380 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1381 + ); 1382 + } 1383 + 1384 + final parentFeedViewPost = FeedViewPost(post: parent!.postView!); 1385 + return Column( 1386 + crossAxisAlignment: CrossAxisAlignment.start, 1387 + children: [ 1388 + PostCardWithActions( 1389 + key: ValueKey('profile_reply_parent_${parentFeedViewPost.post.uri}'), 1390 + feedViewPost: parentFeedViewPost, 1391 + accountDid: accountDid, 1392 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 1393 + ), 1394 + _buildThreadConnector(context), 1395 + PostCardWithActions( 1396 + key: ValueKey('profile_reply_child_${feedViewPost.post.uri}'), 1397 + feedViewPost: feedViewPost, 1398 + accountDid: accountDid, 1399 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 1400 + ), 1401 + ], 1402 + ); 1403 + } 1404 + 1405 + Widget _buildThreadConnector(BuildContext context) { 1406 + return SizedBox( 1407 + height: 16, 1408 + child: Row( 1409 + children: [ 1410 + const SizedBox(width: 37), 1411 + Container(width: 2, color: Theme.of(context).dividerColor), 1412 + ], 1413 + ), 1414 + ); 1415 + } 1416 + } 1417 + 1110 1418 class _SuggestedFollowsTab extends StatefulWidget { 1111 1419 const _SuggestedFollowsTab({required this.actor, required this.onProfileTap}); 1112 1420 ··· 1165 1473 actor: widget.actor, 1166 1474 padding: const EdgeInsets.symmetric(vertical: 8), 1167 1475 onProfileTap: widget.onProfileTap, 1476 + ), 1477 + ); 1478 + } 1479 + } 1480 + 1481 + class _ProfileLikedPostsPane extends StatefulWidget { 1482 + const _ProfileLikedPostsPane({required this.actor, required this.profileRepository}); 1483 + 1484 + final String actor; 1485 + final ProfileRepository profileRepository; 1486 + 1487 + @override 1488 + State<_ProfileLikedPostsPane> createState() => _ProfileLikedPostsPaneState(); 1489 + } 1490 + 1491 + class _ProfileLikedPostsPaneState extends State<_ProfileLikedPostsPane> { 1492 + List<FeedViewPost> _posts = const []; 1493 + String? _cursor; 1494 + bool _isLoading = true; 1495 + bool _isLoadingMore = false; 1496 + bool _hasMore = true; 1497 + String? _error; 1498 + 1499 + @override 1500 + void initState() { 1501 + super.initState(); 1502 + _loadInitial(); 1503 + } 1504 + 1505 + @override 1506 + void didUpdateWidget(covariant _ProfileLikedPostsPane oldWidget) { 1507 + super.didUpdateWidget(oldWidget); 1508 + if (oldWidget.actor != widget.actor) { 1509 + _loadInitial(); 1510 + } 1511 + } 1512 + 1513 + Future<void> _loadInitial() async { 1514 + setState(() { 1515 + _isLoading = true; 1516 + _error = null; 1517 + _posts = const []; 1518 + _cursor = null; 1519 + _hasMore = true; 1520 + }); 1521 + 1522 + try { 1523 + final page = await widget.profileRepository.getActorLikes(actor: widget.actor, limit: 50); 1524 + if (!mounted) return; 1525 + setState(() { 1526 + _posts = page.posts; 1527 + _cursor = page.cursor; 1528 + _hasMore = page.cursor != null; 1529 + _isLoading = false; 1530 + }); 1531 + } catch (e) { 1532 + if (!mounted) return; 1533 + setState(() { 1534 + _error = 'Failed to load liked posts: $e'; 1535 + _isLoading = false; 1536 + }); 1537 + } 1538 + } 1539 + 1540 + Future<void> _refresh() async { 1541 + await _loadInitial(); 1542 + } 1543 + 1544 + Future<void> _loadMore() async { 1545 + if (_isLoadingMore || !_hasMore || _cursor == null) { 1546 + return; 1547 + } 1548 + setState(() => _isLoadingMore = true); 1549 + 1550 + try { 1551 + final page = await widget.profileRepository.getActorLikes(actor: widget.actor, cursor: _cursor, limit: 50); 1552 + if (!mounted) return; 1553 + setState(() { 1554 + _posts = [..._posts, ...page.posts]; 1555 + _cursor = page.cursor; 1556 + _hasMore = page.cursor != null; 1557 + _isLoadingMore = false; 1558 + }); 1559 + } catch (_) { 1560 + if (!mounted) return; 1561 + setState(() => _isLoadingMore = false); 1562 + } 1563 + } 1564 + 1565 + @override 1566 + Widget build(BuildContext context) { 1567 + if (_isLoading) { 1568 + return const Center(child: CircularProgressIndicator()); 1569 + } 1570 + 1571 + if (_error != null) { 1572 + return Center( 1573 + child: Column( 1574 + mainAxisSize: MainAxisSize.min, 1575 + children: [ 1576 + Text(_error!), 1577 + const SizedBox(height: 12), 1578 + FilledButton(onPressed: _loadInitial, child: const Text('Retry')), 1579 + ], 1580 + ), 1581 + ); 1582 + } 1583 + 1584 + if (_posts.isEmpty) { 1585 + return const Center(child: Text('No liked posts yet')); 1586 + } 1587 + 1588 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 1589 + return RefreshIndicator( 1590 + onRefresh: _refresh, 1591 + child: NotificationListener<ScrollNotification>( 1592 + onNotification: (notification) { 1593 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300) { 1594 + _loadMore(); 1595 + } 1596 + return false; 1597 + }, 1598 + child: ListView.builder( 1599 + key: const PageStorageKey<String>('profile-liked-posts-list'), 1600 + itemCount: _posts.length + (_isLoadingMore ? 1 : 0), 1601 + itemBuilder: (context, index) { 1602 + if (index >= _posts.length) { 1603 + return const Padding( 1604 + padding: EdgeInsets.all(16), 1605 + child: Center(child: CircularProgressIndicator()), 1606 + ); 1607 + } 1608 + return PostCardWithActions( 1609 + feedViewPost: _posts[index], 1610 + accountDid: accountDid, 1611 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1612 + ); 1613 + }, 1614 + ), 1168 1615 ), 1169 1616 ); 1170 1617 }
+3 -3
lib/features/settings/presentation/settings_screen.dart
··· 91 91 ), 92 92 _SettingsTile( 93 93 icon: Icons.bookmark_outline, 94 - title: 'Saved Posts', 95 - subtitle: 'View your saved posts', 96 - onTap: () => context.push('/saved'), 94 + title: 'Bookmarks & Likes', 95 + subtitle: 'View your bookmarked and liked posts', 96 + onTap: () => context.push('/bookmarks'), 97 97 ), 98 98 _SettingsTile( 99 99 icon: Icons.videocam_outlined,
+5 -38
lib/shared/presentation/helpers/navigation_helpers.dart
··· 3 3 4 4 /// Profile navigation helper 5 5 /// 6 - /// This avoids pushing a second shell stack from top-level routes like `/post`, 7 - /// that can duplicate navigator keys and trip a framework assertion. 6 + /// Profile routes live inside the stateful app shell. Using imperative `push` 7 + /// for shell destinations can stack a second shell instance and collide 8 + /// navigator keys. Always use declarative `go` for profile navigation. 8 9 Future<T?>? navigateToProfile<T>(BuildContext context, String actorDid) { 9 10 final router = GoRouter.maybeOf(context); 10 11 if (router == null) { ··· 18 19 19 20 final normalizedActor = actor.startsWith('@') ? actor.substring(1) : actor; 20 21 final location = '/profile/${Uri.encodeComponent(normalizedActor)}'; 21 - final currentPath = _currentPath(context); 22 - 23 - if (!_isStatefulShellPath(currentPath)) { 24 - router.go(location); 25 - return null; 26 - } 27 - 28 - return router.push<T>(location); 22 + router.go(location); 23 + return null; 29 24 } 30 25 31 26 Future<T?>? navigateToPost<T>(BuildContext context, String postUri) { ··· 36 31 37 32 return router.push<T>('/post?uri=${Uri.encodeQueryComponent(postUri)}'); 38 33 } 39 - 40 - String _currentPath(BuildContext context) { 41 - try { 42 - return GoRouterState.of(context).uri.path; 43 - } catch (_) { 44 - return ''; 45 - } 46 - } 47 - 48 - bool _isStatefulShellPath(String path) { 49 - if (path == '/') { 50 - return true; 51 - } 52 - 53 - return path == '/feeds' || 54 - path.startsWith('/feeds/') || 55 - path == '/feed' || 56 - path.startsWith('/feed/') || 57 - path == '/trending' || 58 - path.startsWith('/trending/') || 59 - path == '/settings' || 60 - path.startsWith('/settings/') || 61 - path == '/search' || 62 - path.startsWith('/search/') || 63 - path == '/alerts' || 64 - path.startsWith('/alerts/') || 65 - path.startsWith('/profile/'); 66 - }
+5 -1
lib/shared/presentation/widgets/animated_refresh_indicator.dart
··· 36 36 return; 37 37 } 38 38 39 + if (!mounted) { 40 + return; 41 + } 42 + 39 43 setState(() => _refreshing = true); 40 44 unawaited(_rotationController.repeat()); 41 45 42 46 try { 43 47 await widget.onRefresh(); 44 48 } finally { 45 - _rotationController.stop(); 46 49 if (mounted) { 50 + _rotationController.stop(); 47 51 setState(() => _refreshing = false); 48 52 } 49 53 }
+2 -1
test/features/feed/data/feed_repository_test.dart
··· 227 227 228 228 group('FeedFilter', () { 229 229 test('has expected filter values', () { 230 - expect(FeedFilter.values.length, 3); 230 + expect(FeedFilter.values.length, 4); 231 + expect(FeedFilter.values, contains(FeedFilter.postsWithReplies)); 231 232 expect(FeedFilter.values, contains(FeedFilter.postsNoReplies)); 232 233 expect(FeedFilter.values, contains(FeedFilter.postsWithMedia)); 233 234 expect(FeedFilter.values, contains(FeedFilter.postsAndAuthorThreads));
+3 -3
test/features/feed/presentation/saved_posts_screen_test.dart
··· 100 100 await tester.pumpWidget(buildSubject()); 101 101 await tester.pump(); 102 102 103 - expect(find.text('No saved posts'), findsOneWidget); 104 - expect(find.text('Posts you save will appear here'), findsOneWidget); 103 + expect(find.text('No bookmarks'), findsOneWidget); 104 + expect(find.text('Posts you bookmark will appear here'), findsOneWidget); 105 105 }); 106 106 107 107 testWidgets('renders PostCardWithActions for a saved post with valid postJson', (tester) async { ··· 131 131 await tester.pumpWidget(buildSubject()); 132 132 await tester.pump(); 133 133 134 - expect(find.text('Saved Post'), findsOneWidget); 134 + expect(find.text('Bookmarked Post'), findsOneWidget); 135 135 expect(find.byType(PostCardWithActions), findsNothing); 136 136 }); 137 137
+119 -6
test/features/profile/presentation/profile_screen_test.dart
··· 4 4 import 'package:bloc_test/bloc_test.dart'; 5 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 - import 'package:bluesky/app_bsky_feed_post.dart'; 7 + import 'package:bluesky/app_bsky_feed_post.dart' hide ReplyRef; 8 8 import 'package:flutter/material.dart'; 9 9 import 'package:flutter_bloc/flutter_bloc.dart'; 10 10 import 'package:flutter_test/flutter_test.dart'; ··· 175 175 expect(find.text('River Tam'), findsOneWidget); 176 176 }); 177 177 178 - testWidgets('shows Saved Posts button on own profile', (tester) async { 178 + testWidgets('shows separate Bookmarks and Liked buttons on own profile', (tester) async { 179 179 useLargeScreen(tester); 180 180 await tester.pumpWidget(buildSubject()); 181 181 182 - expect(find.text('Saved Posts'), findsOneWidget); 182 + expect(find.text('Bookmarks'), findsOneWidget); 183 + expect(find.text('Liked'), findsOneWidget); 183 184 }); 184 185 185 - testWidgets('does not show Saved Posts button on other profiles', (tester) async { 186 + testWidgets('does not show Bookmarks/Liked buttons on other profiles', (tester) async { 186 187 useLargeScreen(tester); 187 188 const otherProfile = ProfileViewDetailed( 188 189 did: 'did:plc:other', ··· 214 215 215 216 await tester.pumpWidget(widget); 216 217 217 - expect(find.text('Saved Posts'), findsNothing); 218 + expect(find.text('Bookmarks'), findsNothing); 219 + expect(find.text('Liked'), findsNothing); 218 220 }); 219 221 220 222 testWidgets('maps tabs to the expected server filters', (tester) async { ··· 225 227 await tester.pump(); 226 228 227 229 verify( 228 - () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsAndAuthorThreads)), 230 + () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsWithReplies)), 229 231 ).called(1); 230 232 231 233 await tester.tap(find.text('MEDIA')); ··· 236 238 ).called(1); 237 239 }); 238 240 241 + testWidgets('switching feed tabs does not trigger an extra profile reload', (tester) async { 242 + useLargeScreen(tester); 243 + await tester.pumpWidget(buildSubject()); 244 + 245 + verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); 246 + 247 + await tester.tap(find.text('REPLIES')); 248 + await tester.pump(); 249 + await tester.tap(find.text('QUOTES')); 250 + await tester.pump(); 251 + await tester.tap(find.text('REPOSTS')); 252 + await tester.pump(); 253 + 254 + verifyNever(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))); 255 + }); 256 + 239 257 testWidgets('other profiles show a suggested follows tab with loaded suggestions', (tester) async { 240 258 useLargeScreen(tester); 241 259 const otherProfile = ProfileViewDetailed( ··· 290 308 await tester.pumpAndSettle(); 291 309 292 310 expect(find.text('SUGGESTED'), findsOneWidget); 311 + expect(find.text('LIKED'), findsOneWidget); 293 312 313 + await tester.ensureVisible(find.text('SUGGESTED')); 294 314 await tester.tap(find.text('SUGGESTED')); 295 315 await tester.pumpAndSettle(); 296 316 ··· 561 581 FeedState feedStateWith(List<FeedViewPost> p) => 562 582 FeedState.loaded(actor: 'did:plc:me', posts: p, filter: FeedFilter.postsNoReplies, hasMore: false); 563 583 584 + FeedViewPost makeReplyWithParent(String id) { 585 + final parentRecord = FeedPostRecord(text: 'Parent $id', createdAt: DateTime.utc(2026, 3, 1)); 586 + final parentPost = PostView( 587 + uri: AtUri('at://did:plc:parent/app.bsky.feed.post/parent-$id'), 588 + cid: 'cid-parent-$id', 589 + author: const ProfileViewBasic( 590 + did: 'did:plc:parent', 591 + handle: 'parent.bsky.social', 592 + displayName: 'Parent User', 593 + ), 594 + record: parentRecord.toJson(), 595 + indexedAt: DateTime.utc(2026, 3, 1), 596 + ); 597 + final replyRecord = FeedPostRecord( 598 + text: 'Reply $id', 599 + createdAt: DateTime.utc(2026, 3, 1, 0, 5), 600 + reply: null, 601 + ); 602 + 603 + return FeedViewPost( 604 + post: PostView( 605 + uri: AtUri('at://did:plc:me/app.bsky.feed.post/reply-$id'), 606 + cid: 'cid-reply-$id', 607 + author: const ProfileViewBasic(did: 'did:plc:me', handle: 'me.bsky.social', displayName: 'River Tam'), 608 + record: { 609 + ...replyRecord.toJson(), 610 + 'reply': { 611 + r'$type': 'app.bsky.feed.post#replyRef', 612 + 'root': {'uri': parentPost.uri.toString(), 'cid': parentPost.cid}, 613 + 'parent': {'uri': parentPost.uri.toString(), 'cid': parentPost.cid}, 614 + }, 615 + }, 616 + indexedAt: DateTime.utc(2026, 3, 1, 0, 5), 617 + ), 618 + reply: ReplyRef( 619 + root: UReplyRefRoot.postView(data: parentPost), 620 + parent: UReplyRefParent.postView(data: parentPost), 621 + ), 622 + ); 623 + } 624 + 564 625 /// Builds the profile screen with [posts] in the feed and the given SettingsCubit controlling layout mode. 565 626 Widget buildWithPosts(WidgetTester tester, MockSettingsCubit settCubit) { 566 627 useLargeScreen(tester); ··· 645 706 verifyNever(() => feedBloc.add(const FeedRefreshRequested())); 646 707 647 708 await streamCtrl.close(); 709 + }); 710 + 711 + testWidgets('replies tab renders parent + reply thread-style pair', (tester) async { 712 + final cubit = MockSettingsCubit(); 713 + when(() => cubit.state).thenReturn(settingsStateWith(FeedLayout.compact)); 714 + whenListen(cubit, const Stream<SettingsState>.empty(), initialState: settingsStateWith(FeedLayout.compact)); 715 + 716 + final replyPost = makeReplyWithParent('1'); 717 + final repliesState = FeedState.loaded( 718 + actor: 'did:plc:me', 719 + posts: [replyPost], 720 + filter: FeedFilter.postsWithReplies, 721 + hasMore: false, 722 + ); 723 + when(() => feedBloc.state).thenReturn(repliesState); 724 + whenListen(feedBloc, const Stream<FeedState>.empty(), initialState: repliesState); 725 + 726 + useLargeScreen(tester); 727 + final mockPostActionRepo = MockPostActionRepository(); 728 + final mockSavedPostsCubit = MockSavedPostsCubit(); 729 + final mockPostActionCache = MockPostActionCache(); 730 + when(() => mockSavedPostsCubit.state).thenReturn(const SavedPostsState()); 731 + whenListen(mockSavedPostsCubit, const Stream<SavedPostsState>.empty()); 732 + 733 + await tester.pumpWidget( 734 + MultiRepositoryProvider( 735 + providers: [ 736 + RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepo), 737 + RepositoryProvider<PostActionCache>.value(value: mockPostActionCache), 738 + ], 739 + child: MultiBlocProvider( 740 + providers: [ 741 + BlocProvider<AuthBloc>.value(value: authBloc), 742 + BlocProvider<ProfileBloc>.value(value: profileBloc), 743 + BlocProvider<FeedBloc>.value(value: feedBloc), 744 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 745 + BlocProvider<SettingsCubit>.value(value: cubit), 746 + BlocProvider<SavedPostsCubit>.value(value: mockSavedPostsCubit), 747 + ], 748 + child: const MaterialApp(home: ProfileScreen()), 749 + ), 750 + ), 751 + ); 752 + await tester.pump(); 753 + 754 + await tester.ensureVisible(find.text('REPLIES')); 755 + await tester.tap(find.text('REPLIES')); 756 + await tester.pump(); 757 + await tester.pump(const Duration(milliseconds: 300)); 758 + 759 + expect(find.text('Parent 1', findRichText: true), findsWidgets); 760 + expect(find.text('Reply 1', findRichText: true), findsOneWidget); 648 761 }); 649 762 }); 650 763
+41
test/shared/presentation/widgets/animated_refresh_indicator_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 6 + 7 + void main() { 8 + testWidgets('does not call animation controller methods after dispose during refresh', (tester) async { 9 + final completer = Completer<void>(); 10 + var refreshStarted = false; 11 + 12 + await tester.pumpWidget( 13 + MaterialApp( 14 + home: Scaffold( 15 + body: AnimatedRefreshIndicator( 16 + onRefresh: () { 17 + refreshStarted = true; 18 + return completer.future; 19 + }, 20 + child: ListView( 21 + physics: const AlwaysScrollableScrollPhysics(), 22 + children: const [SizedBox(height: 1200)], 23 + ), 24 + ), 25 + ), 26 + ), 27 + ); 28 + 29 + await tester.drag(find.byType(ListView), const Offset(0, 300)); 30 + await tester.pump(); 31 + await tester.pump(const Duration(milliseconds: 300)); 32 + 33 + expect(refreshStarted, isTrue); 34 + 35 + await tester.pumpWidget(const SizedBox.shrink()); 36 + completer.complete(); 37 + await tester.pumpAndSettle(); 38 + 39 + expect(tester.takeException(), isNull); 40 + }); 41 + }