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: feed search

+576 -30
+42
lib/features/search/bloc/search_bloc.dart
··· 84 84 } catch (error) { 85 85 emit(SearchState.error(query: query, message: 'Failed to search actors: $error')); 86 86 } 87 + } else if (currentTab == SearchTab.feeds) { 88 + emit(SearchState.loadingFeeds(query: query)); 89 + 90 + try { 91 + final result = await _searchRepository.searchFeedGenerators(query: query, limit: 25); 92 + await _database.addSearchHistoryEntry(query: query, type: 'feeds', accountDid: _accountDid); 93 + final history = await _database.getSearchHistory(_accountDid, limit: 50); 94 + 95 + emit( 96 + SearchState.loadedFeeds( 97 + query: query, 98 + feeds: result.feeds, 99 + cursor: result.cursor, 100 + ).copyWith(searchHistory: history), 101 + ); 102 + } catch (error) { 103 + emit(SearchState.error(query: query, message: 'Failed to search feeds: $error')); 104 + } 87 105 } else { 88 106 emit(SearchState.loadingStarterPacks(query: query)); 89 107 ··· 143 161 query: state.query, 144 162 starterPacks: [...state.starterPacks, ...result.starterPacks], 145 163 starterPacksCursor: result.cursor, 164 + ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 165 + ); 166 + } catch (error) { 167 + emit(state.copyWith(isLoadingMore: false)); 168 + } 169 + return; 170 + } 171 + 172 + if (state.currentTab == SearchTab.feeds) { 173 + if (state.cursor == null) return; 174 + 175 + emit(state.copyWith(isLoadingMore: true)); 176 + 177 + try { 178 + final result = await _searchRepository.searchFeedGenerators( 179 + query: state.query, 180 + cursor: state.cursor, 181 + limit: 25, 182 + ); 183 + emit( 184 + SearchState.loadedFeeds( 185 + query: state.query, 186 + feeds: [...state.feeds, ...result.feeds], 187 + cursor: result.cursor, 146 188 ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 147 189 ); 148 190 } catch (error) {
+14 -2
lib/features/search/bloc/search_state.dart
··· 1 1 part of 'search_bloc.dart'; 2 2 3 - enum SearchTab { posts, actors, starterPacks } 3 + enum SearchTab { posts, actors, feeds, starterPacks } 4 4 5 5 extension SearchTabLabel on SearchTab { 6 6 String get label => switch (this) { 7 7 SearchTab.posts => 'Posts', 8 8 SearchTab.actors => 'People', 9 + SearchTab.feeds => 'Feeds', 9 10 SearchTab.starterPacks => 'Starter Packs', 10 11 }; 11 12 } ··· 39 40 this.currentSort = 'top', 40 41 this.posts = const [], 41 42 this.actors = const [], 43 + this.feeds = const [], 42 44 this.starterPacks = const [], 43 45 this.cursor, 44 46 this.starterPacksCursor, ··· 57 59 const SearchState.loadingActors({required String query}) 58 60 : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.actors); 59 61 62 + const SearchState.loadingFeeds({required String query}) 63 + : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.feeds); 64 + 60 65 const SearchState.loadingStarterPacks({required String query}) 61 66 : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.starterPacks); 62 67 ··· 78 83 const SearchState.loadedActors({required String query, required List<ProfileView> actors, String? cursor}) 79 84 : this._(status: SearchStatus.loaded, query: query, currentTab: SearchTab.actors, actors: actors, cursor: cursor); 80 85 86 + const SearchState.loadedFeeds({required String query, required List<GeneratorView> feeds, String? cursor}) 87 + : this._(status: SearchStatus.loaded, query: query, currentTab: SearchTab.feeds, feeds: feeds, cursor: cursor); 88 + 81 89 const SearchState.loadedStarterPacks({ 82 90 required String query, 83 91 required List<StarterPackViewBasic> starterPacks, ··· 99 107 final String currentSort; 100 108 final List<PostView> posts; 101 109 final List<ProfileView> actors; 110 + final List<GeneratorView> feeds; 102 111 final List<StarterPackViewBasic> starterPacks; 103 112 final String? cursor; 104 113 final String? starterPacksCursor; ··· 110 119 111 120 bool get isLoading => status == SearchStatus.loading; 112 121 bool get hasError => status == SearchStatus.error; 113 - bool get hasResults => posts.isNotEmpty || actors.isNotEmpty || starterPacks.isNotEmpty; 122 + bool get hasResults => posts.isNotEmpty || actors.isNotEmpty || feeds.isNotEmpty || starterPacks.isNotEmpty; 114 123 bool get hasMore => cursor != null || starterPacksCursor != null; 115 124 116 125 SearchState copyWith({ ··· 120 129 String? currentSort, 121 130 List<PostView>? posts, 122 131 List<ProfileView>? actors, 132 + List<GeneratorView>? feeds, 123 133 List<StarterPackViewBasic>? starterPacks, 124 134 String? cursor, 125 135 String? starterPacksCursor, ··· 136 146 currentSort: currentSort ?? this.currentSort, 137 147 posts: posts ?? this.posts, 138 148 actors: actors ?? this.actors, 149 + feeds: feeds ?? this.feeds, 139 150 starterPacks: starterPacks ?? this.starterPacks, 140 151 cursor: cursor ?? this.cursor, 141 152 starterPacksCursor: starterPacksCursor ?? this.starterPacksCursor, ··· 155 166 currentSort, 156 167 posts, 157 168 actors, 169 + feeds, 158 170 starterPacks, 159 171 cursor, 160 172 starterPacksCursor,
+18
lib/features/search/data/search_repository.dart
··· 55 55 return SearchStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); 56 56 } 57 57 58 + Future<SearchFeedsResult> searchFeedGenerators({required String query, String? cursor, int limit = 25}) async { 59 + final response = await _bluesky.unspecced.getPopularFeedGenerators( 60 + query: query, 61 + cursor: cursor, 62 + limit: limit, 63 + $headers: await _moderationService?.headersForRequest(), 64 + ); 65 + 66 + return SearchFeedsResult(feeds: response.data.feeds, cursor: response.data.cursor); 67 + } 68 + 58 69 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 10}) async { 59 70 final response = await _bluesky.actor.searchActorsTypeahead( 60 71 q: query, ··· 114 125 final List<StarterPackViewBasic> starterPacks; 115 126 final String? cursor; 116 127 } 128 + 129 + class SearchFeedsResult { 130 + SearchFeedsResult({required this.feeds, this.cursor}); 131 + 132 + final List<GeneratorView> feeds; 133 + final String? cursor; 134 + }
+139 -24
lib/features/search/presentation/search_screen.dart
··· 9 9 import 'package:lazurite/core/router/app_shell.dart'; 10 10 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 11 11 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 12 + import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 12 13 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 13 14 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 14 15 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; ··· 47 48 48 49 void _onSearchChanged() { 49 50 setState(() {}); 50 - context.read<SearchBloc>().add(TypeaheadRequested(query: _searchController.text)); 51 51 } 52 52 53 53 void _onScroll() { ··· 78 78 79 79 void _onHistoryTap(String query, String type) { 80 80 _searchController.text = query; 81 - final tab = type == 'posts' ? SearchTab.posts : SearchTab.actors; 81 + final tab = switch (type) { 82 + 'posts' => SearchTab.posts, 83 + 'feeds' => SearchTab.feeds, 84 + 'starter_packs' => SearchTab.starterPacks, 85 + _ => SearchTab.actors, 86 + }; 82 87 context.read<SearchBloc>().add(SearchTabChanged(tab: tab)); 83 88 context.read<SearchBloc>().add(QuerySubmitted(query: query)); 84 89 _focusNode.unfocus(); ··· 321 326 children: [ 322 327 _buildTab(context, SearchTab.posts, state), 323 328 _buildTab(context, SearchTab.actors, state), 329 + _buildTab(context, SearchTab.feeds, state), 324 330 _buildTab(context, SearchTab.starterPacks, state), 325 331 ], 326 332 ), ··· 402 408 } 403 409 404 410 Widget _buildBody(BuildContext context, SearchState state) { 405 - if (state.typeaheadActors.isNotEmpty && _searchController.text.startsWith('@')) { 406 - return _buildTypeaheadResults(context, state); 407 - } 408 - 409 411 if (state.query.isEmpty) { 410 412 return _buildSearchHistory(context, state); 411 413 } ··· 438 440 439 441 if (state.currentTab == SearchTab.posts) { 440 442 return _buildPostResults(context, state); 443 + } else if (state.currentTab == SearchTab.feeds) { 444 + return _buildFeedResults(context, state); 441 445 } else if (state.currentTab == SearchTab.starterPacks) { 442 446 return _buildStarterPackResults(context, state); 443 447 } else { ··· 468 472 itemCount: history.length, 469 473 itemBuilder: (context, index) { 470 474 final entry = history[index]; 471 - final label = entry.type == 'posts' ? 'Posts' : 'People'; 475 + final label = switch (entry.type) { 476 + 'posts' => 'Posts', 477 + 'feeds' => 'Feeds', 478 + 'starter_packs' => 'Starter Packs', 479 + _ => 'People', 480 + }; 472 481 return Dismissible( 473 482 key: Key('history_${entry.id}'), 474 483 direction: DismissDirection.endToStart, ··· 490 499 ), 491 500 ), 492 501 ], 493 - ); 494 - } 495 - 496 - Widget _buildTypeaheadResults(BuildContext context, SearchState state) { 497 - return ListView.builder( 498 - itemCount: state.typeaheadActors.length, 499 - itemBuilder: (context, index) { 500 - final actor = state.typeaheadActors[index]; 501 - return _ActorListTile( 502 - actor: actor, 503 - onTap: () { 504 - _searchController.text = '@${actor.handle}'; 505 - _onSubmit('@${actor.handle}'); 506 - }, 507 - ); 508 - }, 509 502 ); 510 503 } 511 504 ··· 549 542 ); 550 543 } 551 544 545 + Widget _buildFeedResults(BuildContext context, SearchState state) { 546 + final feeds = state.feeds; 547 + if (feeds.isEmpty) { 548 + return Center(child: Text('No feeds found', style: Theme.of(context).textTheme.bodyLarge)); 549 + } 550 + 551 + return ListView.builder( 552 + controller: _scrollController, 553 + itemCount: feeds.length + (state.isLoadingMore ? 1 : 0), 554 + itemBuilder: (context, index) { 555 + if (index == feeds.length) { 556 + return const Center( 557 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 558 + ); 559 + } 560 + 561 + final feed = feeds[index]; 562 + return _FeedResultTile( 563 + feed: feed, 564 + onAdded: (displayName) { 565 + final messenger = ScaffoldMessenger.of(context); 566 + messenger.hideCurrentSnackBar(); 567 + messenger.showSnackBar( 568 + SnackBar( 569 + content: Text('Added $displayName to your saved feeds'), 570 + action: SnackBarAction(label: 'Manage', onPressed: () => GoRouter.maybeOf(context)?.push('/feeds')), 571 + ), 572 + ); 573 + }, 574 + ); 575 + }, 576 + ); 577 + } 578 + 552 579 Widget _buildStarterPackResults(BuildContext context, SearchState state) { 553 580 final packs = state.starterPacks; 554 581 if (packs.isEmpty) { ··· 925 952 } 926 953 } 927 954 955 + class _FeedResultTile extends StatelessWidget { 956 + const _FeedResultTile({required this.feed, required this.onAdded}); 957 + 958 + final GeneratorView feed; 959 + final ValueChanged<String> onAdded; 960 + 961 + @override 962 + Widget build(BuildContext context) { 963 + final isAdded = context.select<FeedPreferencesCubit, bool>( 964 + (cubit) => cubit.state.feeds.any((savedFeed) => savedFeed.value == feed.uri.toString()), 965 + ); 966 + 967 + return Container( 968 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 969 + decoration: BoxDecoration( 970 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 971 + ), 972 + child: Row( 973 + crossAxisAlignment: CrossAxisAlignment.start, 974 + children: [ 975 + Container( 976 + width: 40, 977 + height: 40, 978 + decoration: BoxDecoration( 979 + borderRadius: BorderRadius.circular(10), 980 + gradient: const LinearGradient(colors: [Color(0xFF08BDBA), Color(0xFF3DDBD9)]), 981 + ), 982 + child: feed.avatar != null 983 + ? ClipRRect( 984 + borderRadius: BorderRadius.circular(10), 985 + child: Image.network( 986 + feed.avatar!, 987 + fit: BoxFit.cover, 988 + errorBuilder: (_, _, _) => const Icon(Icons.rss_feed, color: Colors.white), 989 + ), 990 + ) 991 + : const Icon(Icons.rss_feed, color: Colors.white), 992 + ), 993 + const SizedBox(width: 12), 994 + Expanded( 995 + child: Column( 996 + crossAxisAlignment: CrossAxisAlignment.start, 997 + children: [ 998 + Text( 999 + feed.displayName, 1000 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), 1001 + maxLines: 1, 1002 + overflow: TextOverflow.ellipsis, 1003 + ), 1004 + Text( 1005 + 'by @${feed.creator.handle}', 1006 + style: Theme.of( 1007 + context, 1008 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 1009 + ), 1010 + if (feed.description != null && feed.description!.isNotEmpty) ...[ 1011 + const SizedBox(height: 4), 1012 + Text( 1013 + feed.description!, 1014 + maxLines: 2, 1015 + overflow: TextOverflow.ellipsis, 1016 + style: Theme.of(context).textTheme.bodySmall, 1017 + ), 1018 + ], 1019 + ], 1020 + ), 1021 + ), 1022 + const SizedBox(width: 8), 1023 + OutlinedButton( 1024 + onPressed: isAdded 1025 + ? null 1026 + : () async { 1027 + await context.read<FeedPreferencesCubit>().addFeed( 1028 + type: const SavedFeedType.knownValue(data: KnownSavedFeedType.feed), 1029 + value: feed.uri.toString(), 1030 + pinned: false, 1031 + ); 1032 + if (!context.mounted) return; 1033 + onAdded(feed.displayName); 1034 + }, 1035 + child: Text(isAdded ? 'Added' : '+ Add'), 1036 + ), 1037 + ], 1038 + ), 1039 + ); 1040 + } 1041 + } 1042 + 928 1043 class _FollowButton extends StatefulWidget { 929 1044 const _FollowButton({required this.actor}); 930 1045 ··· 993 1108 const SizedBox(height: 8), 994 1109 Text( 995 1110 'Find posts and people on the network.\n' 996 - 'Type @ to search for users.', 1111 + 'Use Jump to profile to quickly open a user.', 997 1112 textAlign: TextAlign.center, 998 1113 style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), 999 1114 ),
+142 -2
test/features/search/bloc/search_bloc_test.dart
··· 31 31 indexedAt: DateTime.utc(2026, 1, 1), 32 32 ); 33 33 34 + final sampleFeed = GeneratorView( 35 + uri: AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/whats-hot'), 36 + cid: 'cid-feed', 37 + did: 'did:web:feed.example.com', 38 + creator: ProfileView( 39 + did: 'did:plc:feedcreator', 40 + handle: 'feedcreator.bsky.social', 41 + indexedAt: DateTime.utc(2026, 1, 1), 42 + ), 43 + displayName: 'What\'s Hot', 44 + indexedAt: DateTime.utc(2026, 1, 1), 45 + ); 46 + 34 47 final sampleStarterPack = StarterPackViewBasic( 35 48 uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), 36 49 cid: 'cid-pack-1', ··· 78 91 limit: any(named: 'limit'), 79 92 ), 80 93 ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [])); 94 + when( 95 + () => mockRepository.searchFeedGenerators( 96 + query: any(named: 'query'), 97 + cursor: any(named: 'cursor'), 98 + limit: any(named: 'limit'), 99 + ), 100 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [])); 81 101 }); 82 102 83 103 SearchBloc buildBloc() => 84 104 SearchBloc(searchRepository: mockRepository, database: mockDatabase, accountDid: 'did:plc:test'); 85 105 86 106 group('SearchTab enum', () { 87 - test('has posts, actors, and starterPacks values', () { 88 - expect(SearchTab.values, containsAll([SearchTab.posts, SearchTab.actors, SearchTab.starterPacks])); 107 + test('has posts, actors, feeds, and starterPacks values', () { 108 + expect( 109 + SearchTab.values, 110 + containsAll([SearchTab.posts, SearchTab.actors, SearchTab.feeds, SearchTab.starterPacks]), 111 + ); 89 112 }); 90 113 91 114 test('SearchTabLabel returns correct labels', () { 92 115 expect(SearchTab.posts.label, 'Posts'); 93 116 expect(SearchTab.actors.label, 'People'); 117 + expect(SearchTab.feeds.label, 'Feeds'); 94 118 expect(SearchTab.starterPacks.label, 'Starter Packs'); 95 119 }); 96 120 }); ··· 120 144 121 145 test('hasResults is true when starterPacks non-empty', () { 122 146 final state = SearchState.loadedStarterPacks(query: 'test', starterPacks: [sampleStarterPack]); 147 + expect(state.hasResults, isTrue); 148 + }); 149 + 150 + test('hasResults is true when feeds non-empty', () { 151 + final state = SearchState.loadedFeeds(query: 'test', feeds: [sampleFeed]); 123 152 expect(state.hasResults, isTrue); 124 153 }); 125 154 ··· 206 235 ); 207 236 }); 208 237 238 + group('QuerySubmitted - feeds tab', () { 239 + blocTest<SearchBloc, SearchState>( 240 + 'searches feeds when feeds tab is active', 241 + build: buildBloc, 242 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.feeds), 243 + setUp: () { 244 + when( 245 + () => mockRepository.searchFeedGenerators( 246 + query: 'custom feeds', 247 + cursor: any(named: 'cursor'), 248 + limit: any(named: 'limit'), 249 + ), 250 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [sampleFeed], cursor: 'feed-c1')); 251 + }, 252 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'custom feeds')), 253 + expect: () => [ 254 + predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.feeds), 255 + predicate<SearchState>( 256 + (s) => 257 + s.status == SearchStatus.loaded && 258 + s.currentTab == SearchTab.feeds && 259 + s.feeds.length == 1 && 260 + s.cursor == 'feed-c1', 261 + ), 262 + ], 263 + verify: (_) { 264 + verify( 265 + () => mockDatabase.addSearchHistoryEntry(query: 'custom feeds', type: 'feeds', accountDid: 'did:plc:test'), 266 + ).called(1); 267 + }, 268 + ); 269 + 270 + blocTest<SearchBloc, SearchState>( 271 + 'emits error when feed search fails', 272 + build: buildBloc, 273 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.feeds), 274 + setUp: () { 275 + when( 276 + () => mockRepository.searchFeedGenerators( 277 + query: any(named: 'query'), 278 + cursor: any(named: 'cursor'), 279 + limit: any(named: 'limit'), 280 + ), 281 + ).thenThrow(Exception('feed error')); 282 + }, 283 + act: (bloc) => bloc.add(const QuerySubmitted(query: 'fail feed')), 284 + expect: () => [ 285 + predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.feeds), 286 + predicate<SearchState>((s) => s.status == SearchStatus.error && s.errorMessage != null), 287 + ], 288 + ); 289 + }); 290 + 209 291 group('SearchTabChanged', () { 210 292 blocTest<SearchBloc, SearchState>( 211 293 'switches to starterPacks tab and re-searches when query present', ··· 318 400 expect: () => [ 319 401 predicate<SearchState>((s) => s.isLoadingMore), 320 402 predicate<SearchState>((s) => !s.isLoadingMore && s.starterPacks.length == 1), 403 + ], 404 + ); 405 + }); 406 + 407 + group('LoadMoreRequested - feeds tab', () { 408 + blocTest<SearchBloc, SearchState>( 409 + 'loads more feeds using cursor', 410 + build: buildBloc, 411 + seed: () => SearchState.loadedFeeds(query: 'custom', feeds: [sampleFeed], cursor: 'feed-c1'), 412 + setUp: () { 413 + final secondFeed = GeneratorView( 414 + uri: AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/news'), 415 + cid: 'cid-feed-2', 416 + did: 'did:web:news.example.com', 417 + creator: ProfileView(did: 'did:plc:news', handle: 'news.bsky.social', indexedAt: DateTime.utc(2026, 1, 1)), 418 + displayName: 'News', 419 + indexedAt: DateTime.utc(2026, 1, 1), 420 + ); 421 + when( 422 + () => mockRepository.searchFeedGenerators( 423 + query: 'custom', 424 + cursor: 'feed-c1', 425 + limit: any(named: 'limit'), 426 + ), 427 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [secondFeed], cursor: null)); 428 + }, 429 + act: (bloc) => bloc.add(const LoadMoreRequested()), 430 + expect: () => [ 431 + predicate<SearchState>((s) => s.isLoadingMore), 432 + predicate<SearchState>((s) => !s.isLoadingMore && s.feeds.length == 2 && s.cursor == null), 433 + ], 434 + ); 435 + 436 + blocTest<SearchBloc, SearchState>( 437 + 'does not load more feeds when cursor is null', 438 + build: buildBloc, 439 + seed: () => SearchState.loadedFeeds(query: 'custom', feeds: [sampleFeed], cursor: null), 440 + act: (bloc) => bloc.add(const LoadMoreRequested()), 441 + expect: () => [], 442 + ); 443 + 444 + blocTest<SearchBloc, SearchState>( 445 + 'handles feed load more error gracefully', 446 + build: buildBloc, 447 + seed: () => SearchState.loadedFeeds(query: 'custom', feeds: [sampleFeed], cursor: 'feed-c1'), 448 + setUp: () { 449 + when( 450 + () => mockRepository.searchFeedGenerators( 451 + query: any(named: 'query'), 452 + cursor: any(named: 'cursor'), 453 + limit: any(named: 'limit'), 454 + ), 455 + ).thenThrow(Exception('load more failed')); 456 + }, 457 + act: (bloc) => bloc.add(const LoadMoreRequested()), 458 + expect: () => [ 459 + predicate<SearchState>((s) => s.isLoadingMore), 460 + predicate<SearchState>((s) => !s.isLoadingMore && s.feeds.length == 1), 321 461 ], 322 462 ); 323 463 });
+88
test/features/search/data/search_repository_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart' show AtUri; 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 4 import 'package:bluesky/app_bsky_graph_defs.dart'; 4 5 import 'package:flutter_test/flutter_test.dart'; 5 6 import 'package:lazurite/features/search/data/search_repository.dart'; ··· 24 25 'createdAt': '2026-01-01T00:00:00.000Z', 25 26 }, 26 27 creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 28 + indexedAt: DateTime.utc(2026, 1, 1), 29 + ); 30 + 31 + final sampleFeed = GeneratorView( 32 + uri: AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/whats-hot'), 33 + cid: 'cid-feed', 34 + did: 'did:web:feed.example.com', 35 + creator: ProfileView(did: 'did:plc:creator', handle: 'creator.bsky.social', indexedAt: DateTime.utc(2026, 1, 1)), 36 + displayName: 'What\'s Hot', 27 37 indexedAt: DateTime.utc(2026, 1, 1), 28 38 ); 29 39 ··· 101 111 102 112 test('cursor defaults to null', () { 103 113 final result = SearchStarterPacksResult(starterPacks: []); 114 + expect(result.cursor, isNull); 115 + }); 116 + }); 117 + 118 + group('SearchRepository.searchFeedGenerators', () { 119 + test('returns result with feeds and cursor', () async { 120 + when( 121 + () => mockRepository.searchFeedGenerators( 122 + query: 'hot', 123 + cursor: any(named: 'cursor'), 124 + limit: any(named: 'limit'), 125 + ), 126 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [sampleFeed], cursor: 'feed-next')); 127 + 128 + final result = await mockRepository.searchFeedGenerators(query: 'hot'); 129 + 130 + expect(result.feeds.length, 1); 131 + expect(result.cursor, 'feed-next'); 132 + }); 133 + 134 + test('returns empty result when no feeds found', () async { 135 + when( 136 + () => mockRepository.searchFeedGenerators( 137 + query: any(named: 'query'), 138 + cursor: any(named: 'cursor'), 139 + limit: any(named: 'limit'), 140 + ), 141 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [])); 142 + 143 + final result = await mockRepository.searchFeedGenerators(query: 'none'); 144 + 145 + expect(result.feeds, isEmpty); 146 + expect(result.cursor, isNull); 147 + }); 148 + 149 + test('passes cursor for pagination', () async { 150 + when( 151 + () => mockRepository.searchFeedGenerators( 152 + query: 'feeds', 153 + cursor: 'cursor-1', 154 + limit: any(named: 'limit'), 155 + ), 156 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [sampleFeed], cursor: 'cursor-2')); 157 + 158 + final result = await mockRepository.searchFeedGenerators(query: 'feeds', cursor: 'cursor-1'); 159 + 160 + expect(result.cursor, 'cursor-2'); 161 + verify( 162 + () => mockRepository.searchFeedGenerators( 163 + query: 'feeds', 164 + cursor: 'cursor-1', 165 + limit: any(named: 'limit'), 166 + ), 167 + ).called(1); 168 + }); 169 + 170 + test('propagates exceptions', () async { 171 + when( 172 + () => mockRepository.searchFeedGenerators( 173 + query: any(named: 'query'), 174 + cursor: any(named: 'cursor'), 175 + limit: any(named: 'limit'), 176 + ), 177 + ).thenThrow(Exception('network error')); 178 + 179 + expect(() => mockRepository.searchFeedGenerators(query: 'fail'), throwsException); 180 + }); 181 + }); 182 + 183 + group('SearchFeedsResult', () { 184 + test('holds feeds and optional cursor', () { 185 + final result = SearchFeedsResult(feeds: [sampleFeed], cursor: 'cursor'); 186 + expect(result.feeds.length, 1); 187 + expect(result.cursor, 'cursor'); 188 + }); 189 + 190 + test('cursor defaults to null', () { 191 + final result = SearchFeedsResult(feeds: []); 104 192 expect(result.cursor, isNull); 105 193 }); 106 194 });
+133 -2
test/features/search/presentation/search_screen_test.dart
··· 9 9 import 'package:go_router/go_router.dart'; 10 10 import 'package:lazurite/core/database/app_database.dart'; 11 11 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 12 + import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 12 13 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 13 14 import 'package:lazurite/features/search/data/search_repository.dart'; 14 15 import 'package:lazurite/features/search/presentation/search_screen.dart'; ··· 20 21 21 22 class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 22 23 24 + class MockFeedPreferencesCubit extends MockCubit<FeedPreferencesState> implements FeedPreferencesCubit {} 25 + 23 26 void main() { 27 + setUpAll(() { 28 + registerFallbackValue(const SavedFeedType.knownValue(data: KnownSavedFeedType.feed)); 29 + }); 30 + 24 31 group('SearchScreen', () { 25 32 late MockSearchRepository mockSearchRepository; 26 33 late MockAppDatabase mockDatabase; 27 34 late MockConnectivityCubit connectivityCubit; 35 + late MockFeedPreferencesCubit feedPreferencesCubit; 28 36 29 37 setUp(() { 30 38 mockSearchRepository = MockSearchRepository(); 31 39 mockDatabase = MockAppDatabase(); 32 40 connectivityCubit = MockConnectivityCubit(); 41 + feedPreferencesCubit = MockFeedPreferencesCubit(); 33 42 when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 34 43 whenListen( 35 44 connectivityCubit, 36 45 const Stream<ConnectivityState>.empty(), 37 46 initialState: const ConnectivityState.online(), 38 47 ); 48 + when(() => feedPreferencesCubit.state).thenReturn(const FeedPreferencesState.loaded(feeds: [])); 49 + whenListen( 50 + feedPreferencesCubit, 51 + const Stream<FeedPreferencesState>.empty(), 52 + initialState: const FeedPreferencesState.loaded(feeds: []), 53 + ); 54 + when( 55 + () => feedPreferencesCubit.addFeed( 56 + type: any(named: 'type'), 57 + value: any(named: 'value'), 58 + pinned: any(named: 'pinned'), 59 + ), 60 + ).thenAnswer((_) async {}); 39 61 when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); 40 62 when( 41 63 () => mockSearchRepository.searchPosts( ··· 65 87 limit: any(named: 'limit'), 66 88 ), 67 89 ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [])); 90 + when( 91 + () => mockSearchRepository.searchFeedGenerators( 92 + query: any(named: 'query'), 93 + cursor: any(named: 'cursor'), 94 + limit: any(named: 'limit'), 95 + ), 96 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [])); 68 97 }); 69 98 70 99 Widget buildSubject() { ··· 79 108 ), 80 109 ), 81 110 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 111 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 82 112 ], 83 113 child: const SearchScreen(), 84 114 ), ··· 96 126 database: mockDatabase, 97 127 accountDid: 'did:plc:test', 98 128 ), 99 - child: BlocProvider<ConnectivityCubit>.value(value: connectivityCubit, child: const SearchScreen()), 129 + child: MultiBlocProvider( 130 + providers: [ 131 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 132 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 133 + ], 134 + child: const SearchScreen(), 135 + ), 100 136 ), 101 137 ), 102 138 GoRoute( 103 139 path: '/profile/view', 104 140 builder: (context, state) => Scaffold(body: Text('profile:${state.uri.queryParameters['actor']}')), 141 + ), 142 + GoRoute( 143 + path: '/feeds', 144 + builder: (context, state) => const Scaffold(body: Text('feeds-page')), 105 145 ), 106 146 ], 107 147 ); ··· 116 156 expect(find.text('Search posts or people'), findsOneWidget); 117 157 expect(find.text('Posts'), findsOneWidget); 118 158 expect(find.text('People'), findsOneWidget); 159 + expect(find.text('Feeds'), findsOneWidget); 119 160 expect(find.text('Top'), findsNothing); 120 161 expect(find.text('Latest'), findsNothing); 121 162 }); ··· 197 238 ), 198 239 ), 199 240 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 241 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 200 242 ], 201 243 child: const SearchScreen(), 202 244 ), ··· 240 282 ), 241 283 ), 242 284 BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 285 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 243 286 ], 244 287 child: const SearchScreen(), 245 288 ), ··· 309 352 expect(find.text('profile:did:plc:river'), findsOneWidget); 310 353 }); 311 354 355 + testWidgets('main search input with @ does not show autocomplete results', (tester) async { 356 + when( 357 + () => mockSearchRepository.searchActorsTypeahead( 358 + query: any(named: 'query'), 359 + limit: any(named: 'limit'), 360 + ), 361 + ).thenAnswer( 362 + (_) async => const [ 363 + ProfileViewBasic(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 364 + ], 365 + ); 366 + 367 + await tester.pumpWidget(buildSubject()); 368 + await tester.pumpAndSettle(); 369 + 370 + await tester.enterText(find.byType(TextField).first, '@river'); 371 + await tester.pump(const Duration(milliseconds: 400)); 372 + await tester.pumpAndSettle(); 373 + 374 + expect(find.text('River Tam'), findsNothing); 375 + }); 376 + 312 377 testWidgets('jump to profile dialog navigates on enter', (tester) async { 313 378 await tester.pumpWidget(buildRoutedSubject()); 314 379 await tester.pumpAndSettle(); ··· 353 418 expect(find.text('No starter packs found'), findsOneWidget); 354 419 }); 355 420 421 + testWidgets('feed tab shows search results and adds a feed with snackbar action', (tester) async { 422 + final sampleFeed = GeneratorView( 423 + uri: AtUri.parse('at://did:plc:feed/app.bsky.feed.generator/whats-hot'), 424 + cid: 'cid-feed', 425 + did: 'did:web:feed.example.com', 426 + creator: ProfileView( 427 + did: 'did:plc:creator', 428 + handle: 'creator.bsky.social', 429 + indexedAt: DateTime.utc(2026, 1, 1), 430 + ), 431 + displayName: 'What\'s Hot', 432 + indexedAt: DateTime.utc(2026, 1, 1), 433 + ); 434 + 435 + when( 436 + () => mockSearchRepository.searchFeedGenerators( 437 + query: any(named: 'query'), 438 + cursor: any(named: 'cursor'), 439 + limit: any(named: 'limit'), 440 + ), 441 + ).thenAnswer((_) async => SearchFeedsResult(feeds: [sampleFeed])); 442 + 443 + when( 444 + () => mockDatabase.addSearchHistoryEntry( 445 + query: any(named: 'query'), 446 + type: any(named: 'type'), 447 + accountDid: any(named: 'accountDid'), 448 + ), 449 + ).thenAnswer((_) async {}); 450 + 451 + await tester.pumpWidget(buildRoutedSubject()); 452 + await tester.pumpAndSettle(); 453 + 454 + await tester.tap(find.text('Feeds')); 455 + await tester.pumpAndSettle(); 456 + 457 + await tester.enterText(find.byType(TextField).first, 'hot'); 458 + await tester.testTextInput.receiveAction(TextInputAction.search); 459 + await tester.pumpAndSettle(); 460 + 461 + expect(find.text('What\'s Hot'), findsOneWidget); 462 + expect(find.text('+ Add'), findsOneWidget); 463 + 464 + await tester.tap(find.text('+ Add')); 465 + await tester.pumpAndSettle(); 466 + 467 + verify( 468 + () => feedPreferencesCubit.addFeed( 469 + type: const SavedFeedType.knownValue(data: KnownSavedFeedType.feed), 470 + value: sampleFeed.uri.toString(), 471 + pinned: false, 472 + ), 473 + ).called(1); 474 + expect(find.text('Added What\'s Hot to your saved feeds'), findsOneWidget); 475 + 476 + await tester.tap(find.text('Manage')); 477 + await tester.pumpAndSettle(); 478 + expect(find.text('feeds-page'), findsOneWidget); 479 + }); 480 + 356 481 testWidgets('starter pack results display name and creator handle', (tester) async { 357 482 final samplePack = StarterPackViewBasic( 358 483 uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), ··· 443 568 database: mockDatabase, 444 569 accountDid: 'did:plc:test', 445 570 ), 446 - child: BlocProvider<ConnectivityCubit>.value(value: connectivityCubit, child: const SearchScreen()), 571 + child: MultiBlocProvider( 572 + providers: [ 573 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 574 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 575 + ], 576 + child: const SearchScreen(), 577 + ), 447 578 ), 448 579 ), 449 580 GoRoute(