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: post interactions sheet

+541 -37
+12
lib/features/feed/data/post_action_repository.dart
··· 1 1 import 'package:atproto/com_atproto_repo_strongref.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 3 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 4 + import 'package:bluesky/app_bsky_feed_getlikes.dart'; 5 + import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 4 6 import 'package:bluesky/bluesky.dart'; 5 7 6 8 class PostActionRepository { ··· 51 53 52 54 Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 53 55 final response = await _bluesky.bookmark.getBookmarks(limit: limit, cursor: cursor); 56 + return response.data; 57 + } 58 + 59 + Future<FeedGetLikesOutput> getLikes({required AtUri uri, String? cursor}) async { 60 + final response = await _bluesky.feed.getLikes(uri: uri, limit: 25, cursor: cursor); 61 + return response.data; 62 + } 63 + 64 + Future<FeedGetRepostedByOutput> getRepostedBy({required AtUri uri, String? cursor}) async { 65 + final response = await _bluesky.feed.getRepostedBy(uri: uri, limit: 25, cursor: cursor); 54 66 return response.data; 55 67 } 56 68
+48 -12
lib/features/feed/presentation/post_thread_screen.dart
··· 18 18 import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; 19 19 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 20 20 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 21 + import 'package:lazurite/features/feed/presentation/widgets/post_interactions_sheet.dart'; 21 22 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 22 23 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 23 24 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; ··· 339 340 child: Column( 340 341 crossAxisAlignment: CrossAxisAlignment.start, 341 342 children: [ 343 + const SizedBox(height: 8), 342 344 PostCardWithActions( 343 345 feedViewPost: FeedViewPost(post: thread.post), 344 346 accountDid: accountDid, ··· 651 653 final post = thread.post; 652 654 final record = _parsePostRecord(post.record); 653 655 final timestamp = record?.createdAt ?? post.indexedAt; 656 + 657 + final hasStats = (post.replyCount ?? 0) > 0 || (post.repostCount ?? 0) > 0 || (post.likeCount ?? 0) > 0; 654 658 655 659 return PostCard( 656 660 feedViewPost: FeedViewPost(post: post), 657 661 moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, 658 662 actionBar: Padding( 659 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 663 + padding: const EdgeInsets.symmetric(horizontal: 16), 660 664 child: Column( 661 665 crossAxisAlignment: CrossAxisAlignment.start, 662 666 children: [ 663 - const SizedBox(height: 4), 667 + const SizedBox(height: 10), 664 668 Text( 665 669 _formatTimestamp(timestamp), 666 670 style: Theme.of( 667 671 context, 668 672 ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 669 673 ), 670 - const Divider(), 671 - _buildStats(context, post), 674 + const SizedBox(height: 10), 672 675 const Divider(height: 1), 673 - const SizedBox(height: 4), 676 + if (hasStats) ...[ 677 + const SizedBox(height: 10), 678 + _buildStats(context, post), 679 + const SizedBox(height: 10), 680 + const Divider(height: 1), 681 + ], 682 + const SizedBox(height: 6), 674 683 _buildActionBar(context, post), 684 + const SizedBox(height: 6), 675 685 ], 676 686 ), 677 687 ), ··· 685 695 items.addAll([_buildStat(context, post.replyCount!, 'replies'), const SizedBox(width: 20)]); 686 696 } 687 697 if ((post.repostCount ?? 0) > 0) { 688 - items.addAll([_buildStat(context, post.repostCount!, 'reposts'), const SizedBox(width: 20)]); 698 + items.addAll([ 699 + _buildStat( 700 + context, 701 + post.repostCount!, 702 + 'reposts', 703 + onTap: () => _showInteractions(context, post, showLikes: false), 704 + ), 705 + const SizedBox(width: 20), 706 + ]); 689 707 } 690 708 if ((post.likeCount ?? 0) > 0) { 691 - items.add(_buildStat(context, post.likeCount!, 'likes')); 709 + items.add( 710 + _buildStat(context, post.likeCount!, 'likes', onTap: () => _showInteractions(context, post, showLikes: true)), 711 + ); 692 712 } 693 713 694 714 if (items.isEmpty) return const SizedBox.shrink(); 695 715 696 - return Padding( 697 - padding: const EdgeInsets.symmetric(vertical: 8), 698 - child: Row(children: items), 716 + return Row(children: items); 717 + } 718 + 719 + void _showInteractions(BuildContext context, PostView post, {required bool showLikes}) { 720 + final repository = context.read<PostActionRepository>(); 721 + showModalBottomSheet<void>( 722 + context: context, 723 + isScrollControlled: true, 724 + builder: (_) => PostInteractionsSheet( 725 + postUri: post.uri, 726 + likeCount: post.likeCount ?? 0, 727 + repostCount: post.repostCount ?? 0, 728 + initialTab: showLikes ? InteractionTab.likes : InteractionTab.reposts, 729 + repository: repository, 730 + ), 699 731 ); 700 732 } 701 733 702 - Widget _buildStat(BuildContext context, int count, String label) { 703 - return RichText( 734 + Widget _buildStat(BuildContext context, int count, String label, {VoidCallback? onTap}) { 735 + final text = RichText( 704 736 text: TextSpan( 705 737 children: [ 706 738 TextSpan( ··· 716 748 ], 717 749 ), 718 750 ); 751 + 752 + if (onTap == null) return text; 753 + 754 + return GestureDetector(onTap: onTap, child: text); 719 755 } 720 756 721 757 Widget _buildActionBar(BuildContext context, PostView post) {
+267
lib/features/feed/presentation/widgets/post_interactions_sheet.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 6 + 7 + enum InteractionTab { likes, reposts } 8 + 9 + class PostInteractionsSheet extends StatefulWidget { 10 + const PostInteractionsSheet({ 11 + super.key, 12 + required this.postUri, 13 + required this.likeCount, 14 + required this.repostCount, 15 + required this.repository, 16 + this.initialTab, 17 + }); 18 + 19 + final AtUri postUri; 20 + final int likeCount; 21 + final int repostCount; 22 + final PostActionRepository repository; 23 + final InteractionTab? initialTab; 24 + 25 + @override 26 + State<PostInteractionsSheet> createState() => _PostInteractionsSheetState(); 27 + } 28 + 29 + class _PostInteractionsSheetState extends State<PostInteractionsSheet> { 30 + late InteractionTab _selectedTab; 31 + 32 + final List<ProfileView> _likers = []; 33 + bool _loadingLikes = false; 34 + String? _likesCursor; 35 + bool _likesLoaded = false; 36 + 37 + final List<ProfileView> _reposters = []; 38 + bool _loadingReposts = false; 39 + String? _repostsCursor; 40 + bool _repostsLoaded = false; 41 + 42 + @override 43 + void initState() { 44 + super.initState(); 45 + final initial = widget.initialTab ?? (widget.likeCount > 0 ? InteractionTab.likes : InteractionTab.reposts); 46 + _selectedTab = initial; 47 + if (initial == InteractionTab.likes) { 48 + _loadLikes(); 49 + } else { 50 + _loadReposts(); 51 + } 52 + } 53 + 54 + Future<void> _loadLikes() async { 55 + if (_loadingLikes) return; 56 + setState(() => _loadingLikes = true); 57 + try { 58 + final output = await widget.repository.getLikes(uri: widget.postUri, cursor: _likesCursor); 59 + if (mounted) { 60 + setState(() { 61 + _likers.addAll(output.likes.map((l) => l.actor)); 62 + _likesCursor = output.cursor; 63 + _likesLoaded = true; 64 + }); 65 + } 66 + } catch (_) { 67 + if (mounted) setState(() => _likesLoaded = true); 68 + } finally { 69 + if (mounted) setState(() => _loadingLikes = false); 70 + } 71 + } 72 + 73 + Future<void> _loadReposts() async { 74 + if (_loadingReposts) return; 75 + setState(() => _loadingReposts = true); 76 + try { 77 + final output = await widget.repository.getRepostedBy(uri: widget.postUri, cursor: _repostsCursor); 78 + if (mounted) { 79 + setState(() { 80 + _reposters.addAll(output.repostedBy); 81 + _repostsCursor = output.cursor; 82 + _repostsLoaded = true; 83 + }); 84 + } 85 + } catch (_) { 86 + if (mounted) setState(() => _repostsLoaded = true); 87 + } finally { 88 + if (mounted) setState(() => _loadingReposts = false); 89 + } 90 + } 91 + 92 + void _selectTab(InteractionTab tab) { 93 + setState(() => _selectedTab = tab); 94 + if (tab == InteractionTab.likes && !_likesLoaded) _loadLikes(); 95 + if (tab == InteractionTab.reposts && !_repostsLoaded) _loadReposts(); 96 + } 97 + 98 + @override 99 + Widget build(BuildContext context) { 100 + final colorScheme = Theme.of(context).colorScheme; 101 + final hasBothTabs = widget.likeCount > 0 && widget.repostCount > 0; 102 + 103 + return DraggableScrollableSheet( 104 + initialChildSize: 0.6, 105 + maxChildSize: 0.9, 106 + minChildSize: 0.3, 107 + expand: false, 108 + builder: (context, scrollController) { 109 + return Column( 110 + crossAxisAlignment: CrossAxisAlignment.start, 111 + children: [ 112 + if (hasBothTabs) ...[_buildTabBar(context, colorScheme)] else ...[_buildSectionLabel(context, colorScheme)], 113 + Expanded( 114 + child: _selectedTab == InteractionTab.likes 115 + ? _buildProfileList( 116 + profiles: _likers, 117 + loading: _loadingLikes, 118 + loaded: _likesLoaded, 119 + cursor: _likesCursor, 120 + onLoadMore: _loadLikes, 121 + scrollController: scrollController, 122 + colorScheme: colorScheme, 123 + ) 124 + : _buildProfileList( 125 + profiles: _reposters, 126 + loading: _loadingReposts, 127 + loaded: _repostsLoaded, 128 + cursor: _repostsCursor, 129 + onLoadMore: _loadReposts, 130 + scrollController: scrollController, 131 + colorScheme: colorScheme, 132 + ), 133 + ), 134 + ], 135 + ); 136 + }, 137 + ); 138 + } 139 + 140 + Widget _buildSectionLabel(BuildContext context, ColorScheme colorScheme) { 141 + final label = _selectedTab == InteractionTab.likes ? 'LIKED BY' : 'REPOSTED BY'; 142 + return Padding( 143 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 144 + child: Text( 145 + label, 146 + style: TextStyle( 147 + fontSize: 11, 148 + fontWeight: FontWeight.w700, 149 + letterSpacing: 2.2, 150 + color: colorScheme.onSurfaceVariant, 151 + ), 152 + ), 153 + ); 154 + } 155 + 156 + Widget _buildTabBar(BuildContext context, ColorScheme colorScheme) { 157 + return Padding( 158 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), 159 + child: Row( 160 + children: [ 161 + _buildTabChip( 162 + colorScheme: colorScheme, 163 + tab: InteractionTab.likes, 164 + icon: Icons.favorite_outline, 165 + label: '${widget.likeCount} Likes', 166 + ), 167 + const SizedBox(width: 10), 168 + _buildTabChip( 169 + colorScheme: colorScheme, 170 + tab: InteractionTab.reposts, 171 + icon: Icons.repeat, 172 + label: '${widget.repostCount} Reposts', 173 + ), 174 + ], 175 + ), 176 + ); 177 + } 178 + 179 + Widget _buildTabChip({ 180 + required ColorScheme colorScheme, 181 + required InteractionTab tab, 182 + required IconData icon, 183 + required String label, 184 + }) { 185 + final isSelected = _selectedTab == tab; 186 + return GestureDetector( 187 + onTap: () => _selectTab(tab), 188 + child: AnimatedContainer( 189 + duration: const Duration(milliseconds: 150), 190 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 191 + decoration: BoxDecoration( 192 + color: isSelected ? colorScheme.primary.withValues(alpha: 0.15) : Colors.transparent, 193 + borderRadius: BorderRadius.circular(8), 194 + border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outlineVariant), 195 + ), 196 + child: Row( 197 + mainAxisSize: MainAxisSize.min, 198 + children: [ 199 + Icon(icon, size: 14, color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant), 200 + const SizedBox(width: 6), 201 + Text( 202 + label, 203 + style: TextStyle( 204 + fontSize: 12, 205 + fontWeight: FontWeight.w600, 206 + color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, 207 + ), 208 + ), 209 + ], 210 + ), 211 + ), 212 + ); 213 + } 214 + 215 + Widget _buildProfileList({ 216 + required List<ProfileView> profiles, 217 + required bool loading, 218 + required bool loaded, 219 + required String? cursor, 220 + required VoidCallback onLoadMore, 221 + required ScrollController scrollController, 222 + required ColorScheme colorScheme, 223 + }) { 224 + if (!loaded && loading) { 225 + return const Center(child: CircularProgressIndicator()); 226 + } 227 + 228 + if (loaded && profiles.isEmpty) { 229 + return Center( 230 + child: Text('No interactions yet', style: TextStyle(color: colorScheme.onSurfaceVariant)), 231 + ); 232 + } 233 + 234 + return ListView.builder( 235 + controller: scrollController, 236 + itemCount: profiles.length + (cursor != null ? 1 : 0), 237 + itemBuilder: (context, index) { 238 + if (index == profiles.length) { 239 + if (!loading) onLoadMore(); 240 + return const Padding( 241 + padding: EdgeInsets.all(16), 242 + child: Center(child: CircularProgressIndicator()), 243 + ); 244 + } 245 + 246 + final profile = profiles[index]; 247 + final initials = ((profile.displayName?.isNotEmpty == true ? profile.displayName! : profile.handle)) 248 + .substring(0, 1) 249 + .toUpperCase(); 250 + 251 + return ListTile( 252 + leading: CircleAvatar( 253 + backgroundImage: profile.avatar != null ? NetworkImage(profile.avatar!) : null, 254 + backgroundColor: colorScheme.surfaceContainerHighest, 255 + child: profile.avatar == null ? Text(initials) : null, 256 + ), 257 + title: Text(profile.displayName ?? profile.handle, maxLines: 1, overflow: TextOverflow.ellipsis), 258 + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 259 + onTap: () { 260 + Navigator.pop(context); 261 + GoRouter.maybeOf(context)?.push('/profile/view?actor=${Uri.encodeQueryComponent(profile.did)}'); 262 + }, 263 + ); 264 + }, 265 + ); 266 + } 267 + }
+2
lib/features/profile/presentation/profile_screen.dart
··· 175 175 _loadProfileAndFeed(filter: _feedTabs[index].filter); 176 176 } 177 177 }, 178 + isScrollable: true, 179 + tabAlignment: TabAlignment.start, 178 180 labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), 179 181 unselectedLabelStyle: const TextStyle( 180 182 fontSize: 11,
+50
test/features/feed/data/post_action_repository_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 3 + import 'package:bluesky/app_bsky_feed_getlikes.dart'; 4 + import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 3 5 import 'package:flutter_test/flutter_test.dart'; 4 6 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 5 7 ··· 51 53 @override 52 54 Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 53 55 return const BookmarkGetBookmarksOutput(bookmarks: []); 56 + } 57 + 58 + @override 59 + Future<FeedGetLikesOutput> getLikes({required dynamic uri, String? cursor}) async { 60 + return FeedGetLikesOutput(uri: AtUri.parse(uri.toString()), likes: []); 61 + } 62 + 63 + @override 64 + Future<FeedGetRepostedByOutput> getRepostedBy({required dynamic uri, String? cursor}) async { 65 + return FeedGetRepostedByOutput(uri: AtUri.parse(uri.toString()), repostedBy: []); 54 66 } 55 67 56 68 bool isLiked(String postUri) => _likes.containsKey(postUri); ··· 207 219 final output = await repository.getBookmarks(limit: 10, cursor: 'abc'); 208 220 209 221 expect(output.bookmarks, isEmpty); 222 + }); 223 + }); 224 + 225 + group('getLikes', () { 226 + test('should return empty likes list', () async { 227 + final uri = _createTestUri('abc123'); 228 + 229 + final output = await repository.getLikes(uri: uri); 230 + 231 + expect(output.likes, isEmpty); 232 + expect(output.cursor, isNull); 233 + }); 234 + 235 + test('should accept cursor param', () async { 236 + final uri = _createTestUri('abc123'); 237 + 238 + final output = await repository.getLikes(uri: uri, cursor: 'next-page'); 239 + 240 + expect(output.likes, isEmpty); 241 + }); 242 + }); 243 + 244 + group('getRepostedBy', () { 245 + test('should return empty reposters list', () async { 246 + final uri = _createTestUri('abc123'); 247 + 248 + final output = await repository.getRepostedBy(uri: uri); 249 + 250 + expect(output.repostedBy, isEmpty); 251 + expect(output.cursor, isNull); 252 + }); 253 + 254 + test('should accept cursor param', () async { 255 + final uri = _createTestUri('abc123'); 256 + 257 + final output = await repository.getRepostedBy(uri: uri, cursor: 'next-page'); 258 + 259 + expect(output.repostedBy, isEmpty); 210 260 }); 211 261 }); 212 262 });
+161
test/features/feed/presentation/post_interactions_sheet_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 4 + import 'package:bluesky/app_bsky_feed_getlikes.dart'; 5 + import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 6 + import 'package:flutter/material.dart'; 7 + import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/core/theme/app_theme.dart'; 9 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 10 + import 'package:lazurite/features/feed/presentation/widgets/post_interactions_sheet.dart'; 11 + 12 + class _FakeRepository implements PostActionRepository { 13 + _FakeRepository({this.reposters = const []}); 14 + final List<ProfileView> reposters; 15 + 16 + @override 17 + Future<FeedGetLikesOutput> getLikes({required AtUri uri, String? cursor}) async { 18 + return FeedGetLikesOutput(uri: uri, likes: []); 19 + } 20 + 21 + @override 22 + Future<FeedGetRepostedByOutput> getRepostedBy({required AtUri uri, String? cursor}) async { 23 + return FeedGetRepostedByOutput(uri: uri, repostedBy: reposters); 24 + } 25 + 26 + @override 27 + Future<String> likePost({required AtUri uri, required String cid}) async => ''; 28 + 29 + @override 30 + Future<void> unlikePost({required String likeUri}) async {} 31 + 32 + @override 33 + Future<String> repostPost({required AtUri uri, required String cid}) async => ''; 34 + 35 + @override 36 + Future<void> unrepostPost({required String repostUri}) async {} 37 + 38 + @override 39 + Future<void> deletePost({required String postUri}) async {} 40 + 41 + @override 42 + Future<void> createBookmark({required AtUri uri, required String cid}) async {} 43 + 44 + @override 45 + Future<void> deleteBookmark({required AtUri uri}) async {} 46 + 47 + @override 48 + Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 49 + return const BookmarkGetBookmarksOutput(bookmarks: []); 50 + } 51 + } 52 + 53 + class _FakeLikesRepository extends _FakeRepository { 54 + _FakeLikesRepository(this._likers); 55 + final List<ProfileView> _likers; 56 + 57 + @override 58 + Future<FeedGetLikesOutput> getLikes({required AtUri uri, String? cursor}) async { 59 + return FeedGetLikesOutput( 60 + uri: uri, 61 + likes: _likers.map((p) => Like(indexedAt: DateTime.utc(2026), createdAt: DateTime.utc(2026), actor: p)).toList(), 62 + ); 63 + } 64 + } 65 + 66 + final _testUri = AtUri.parse('at://did:plc:test/app.bsky.feed.post/abc'); 67 + 68 + ProfileView _makeProfile({String handle = 'alice.bsky.social', String? displayName}) { 69 + return ProfileView(did: 'did:plc:$handle', handle: handle, displayName: displayName); 70 + } 71 + 72 + Widget _buildSheet({required PostActionRepository repository, int likeCount = 0, int repostCount = 0}) { 73 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 74 + return MaterialApp( 75 + theme: theme, 76 + home: Scaffold( 77 + body: PostInteractionsSheet( 78 + postUri: _testUri, 79 + likeCount: likeCount, 80 + repostCount: repostCount, 81 + repository: repository, 82 + ), 83 + ), 84 + ); 85 + } 86 + 87 + void main() { 88 + group('PostInteractionsSheet', () { 89 + testWidgets('shows loading indicator while fetching likes', (tester) async { 90 + final repo = _FakeRepository(); 91 + await tester.pumpWidget(_buildSheet(repository: repo, likeCount: 5)); 92 + 93 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 94 + }); 95 + 96 + testWidgets('shows "LIKED BY" label when only likes available', (tester) async { 97 + final repo = _FakeLikesRepository([_makeProfile()]); 98 + await tester.pumpWidget(_buildSheet(repository: repo, likeCount: 1)); 99 + await tester.pump(); 100 + await tester.pump(); 101 + 102 + expect(find.text('LIKED BY'), findsOneWidget); 103 + }); 104 + 105 + testWidgets('shows "REPOSTED BY" label when only reposts available', (tester) async { 106 + final repo = _FakeRepository(reposters: [_makeProfile()]); 107 + await tester.pumpWidget(_buildSheet(repository: repo, repostCount: 1)); 108 + await tester.pump(); 109 + await tester.pump(); 110 + 111 + expect(find.text('REPOSTED BY'), findsOneWidget); 112 + }); 113 + 114 + testWidgets('shows tab chips when both likes and reposts are present', (tester) async { 115 + final repo = _FakeLikesRepository([_makeProfile()]); 116 + await tester.pumpWidget(_buildSheet(repository: repo, likeCount: 3, repostCount: 2)); 117 + await tester.pump(); 118 + await tester.pump(); 119 + 120 + expect(find.text('3 Likes'), findsOneWidget); 121 + expect(find.text('2 Reposts'), findsOneWidget); 122 + }); 123 + 124 + testWidgets('shows empty message when no likers returned', (tester) async { 125 + final repo = _FakeLikesRepository([]); 126 + await tester.pumpWidget(_buildSheet(repository: repo, likeCount: 1)); 127 + await tester.pump(); 128 + await tester.pump(); 129 + 130 + expect(find.text('No interactions yet'), findsOneWidget); 131 + }); 132 + 133 + testWidgets('shows likers list after loading', (tester) async { 134 + final repo = _FakeLikesRepository([ 135 + _makeProfile(handle: 'alice.bsky.social', displayName: 'Alice'), 136 + _makeProfile(handle: 'bob.bsky.social', displayName: 'Bob'), 137 + ]); 138 + await tester.pumpWidget(_buildSheet(repository: repo, likeCount: 2)); 139 + await tester.pump(); 140 + await tester.pump(); 141 + 142 + expect(find.text('Alice'), findsOneWidget); 143 + expect(find.text('Bob'), findsOneWidget); 144 + }); 145 + 146 + testWidgets('shows reposters list when repost tab selected', (tester) async { 147 + final repo = _FakeRepository( 148 + reposters: [_makeProfile(handle: 'carol.bsky.social', displayName: 'Carol')], 149 + ); 150 + await tester.pumpWidget(_buildSheet(repository: repo, likeCount: 3, repostCount: 1)); 151 + await tester.pump(); 152 + await tester.pump(); 153 + 154 + await tester.tap(find.text('1 Reposts')); 155 + await tester.pump(); 156 + await tester.pump(); 157 + 158 + expect(find.text('Carol'), findsOneWidget); 159 + }); 160 + }); 161 + }
-23
test/features/starter_packs/presentation/create_edit_starter_pack_screen_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart' show AtUri; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 - import 'package:bluesky/app_bsky_graph_defs.dart'; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_bloc/flutter_bloc.dart'; 7 6 import 'package:flutter_test/flutter_test.dart'; ··· 23 22 24 23 const userDid = 'did:plc:user'; 25 24 final packUri = AtUri.parse('at://did:plc:user/app.bsky.graph.starterpack/pack-1'); 26 - final refListUri = AtUri.parse('at://did:plc:user/app.bsky.graph.list/list-1'); 27 25 28 26 setUpAll(() { 29 27 registerFallbackValue(AtUri.parse('at://did:plc:fallback/app.bsky.graph.starterpack/fallback')); ··· 33 31 mockRepo = MockStarterPackRepository(); 34 32 mockListRepo = MockListRepository(); 35 33 }); 36 - 37 - StarterPackView buildPackView() { 38 - return StarterPackView( 39 - uri: packUri, 40 - cid: 'cid-pack', 41 - record: const { 42 - r'$type': 'app.bsky.graph.starterpack', 43 - 'name': 'My Pack', 44 - 'list': 'at://did:plc:user/app.bsky.graph.list/list-1', 45 - 'createdAt': '2026-03-22T00:00:00.000Z', 46 - }, 47 - creator: const ProfileViewBasic(did: userDid, handle: 'user.bsky.social'), 48 - list: ListViewBasic( 49 - uri: refListUri, 50 - cid: 'cid-list', 51 - name: 'Starter Pack Members', 52 - purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), 53 - ), 54 - indexedAt: DateTime.utc(2026, 3, 22), 55 - ); 56 - } 57 34 58 35 Widget buildSubject() { 59 36 return MultiRepositoryProvider(
+1 -2
test/features/starter_packs/presentation/starter_pack_detail_screen_test.dart
··· 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 8 import 'package:go_router/go_router.dart'; 9 - import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 10 9 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 11 10 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 12 11 import 'package:mocktail/mocktail.dart'; ··· 75 74 routes: [ 76 75 GoRoute( 77 76 path: '/', 78 - builder: (_, __) => MultiRepositoryProvider( 77 + builder: (_, _) => MultiRepositoryProvider( 79 78 providers: [ 80 79 RepositoryProvider<StarterPackRepository>.value(value: mockRepository), 81 80 if (currentUserDid != null) RepositoryProvider.value(value: currentUserDid),