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.

refactor: disable starter pack search

+289 -334
+3
CHANGELOG.md
··· 52 52 #### 2026-04-01 53 53 54 54 - Profile Context (Blocking/Blocked By, Lists, etc.) section accessible from profiles 55 + - Suggested Follows tab (for non-currently authenticated users) in the Profile screen 56 + - Video upload limits in settings 55 57 56 58 #### 2026-04-11 57 59 ··· 71 73 #### 2026-04-29 72 74 73 75 - Configurable autocomplete/typeahead for login & profile/actor search 76 + - Starter Pack search (not implemented upstream) screen
+4 -1
docs/TODO.md
··· 11 11 ## UI 12 12 13 13 - Show feed icons in the feed management UI 14 - - (bug) In the side menu, navigation items scroll behind the logged in account label 15 14 - We should create a section for "Advanced Features" in the side menu, and include a 16 15 link to dev tools and follow audits. 17 16 - Constellation URL should remain configurable internally but the option to change the 18 17 URL should be removed from the UI. 19 18 - Saved posts should be a tabbed view for local & ATProto/BSky saved posts. 19 + 20 + - Disabled/inactive inputs need distinct visual language and some disabled inputs aren't 21 + actually disabled (starterpack search) 22 + - The Navigation Menu links aren't fully route/context aware. 20 23 21 24 ## UX 22 25
+3 -60
docs/tasks/phase-5.md
··· 7 7 8 8 ## M20 - Starter Pack Search 9 9 10 - ### Core 11 - 12 - - [x] `SearchRepository.searchStarterPacks()` - call `bluesky.graph.searchStarterPacks(q:, limit:, cursor:)`, return result with `List<StarterPackViewBasic>` and cursor 13 - - [x] Add `starterPacks` value to `SearchTab` enum, update `SearchTabLabel` extension 14 - 15 - ### Cubit 16 - 17 - - [x] `SearchBloc` - handle starter packs tab: dispatch search on tab switch if query present, handle `LoadMoreRequested` with cursor pagination 18 - - [x] `SearchState` - add `starterPacks` list and `starterPacksCursor` fields 19 - 20 - ### UI 21 - 22 - - [x] Search screen UI - add third "Starter Packs" tab pill in `_buildTab` row 23 - - [x] Starter pack result tile widget - show name, creator handle, member count, joined stats; reuse pattern from profile starter packs tab 24 - - [x] Tap result → navigate to existing starter pack detail screen (`/starter-pack?uri=`) 25 - - [x] Infinite scroll pagination for starter packs tab 26 - 27 - ### Tests 28 - 29 - - [x] Unit tests: `SearchRepository.searchStarterPacks`, bloc events for new tab, pagination 30 - - [x] Widget tests: third tab renders, results display, empty state, tap navigation 10 + Completed(-ish) [2026-04-29](../../CHANGELOG.md#2026-04-29) 31 11 32 12 ## M21 - Suggested Follows Sheet 33 13 34 - ### Core 35 - 36 - - [x] `ProfileRepository.getSuggestedFollows()` - call `bluesky.graph.getSuggestedFollowsByActor(actor:)`, return `List<ProfileView>` 37 - 38 - ### Cubit 39 - 40 - - [x] `SuggestedFollowsCubit` - `load(actor:)` fetches suggestions, exposes loaded/loading/error states 41 - 42 - ### UI 43 - 44 - - [x] Suggested follows sheet widget - `DraggableScrollableSheet` listing `ProfileView` tiles with follow/unfollow toggle buttons 45 - - [x] Profile screen overflow menu - add "Suggested Follows" `ListTile` entry; hide when viewing own profile 46 - - [x] Tap entry → create cubit, show sheet with `BlocProvider.value`, close cubit on sheet dismiss via `.whenComplete` 47 - - [x] Tap profile tile → pop sheet, navigate to profile screen 48 - - [x] Empty state when no suggestions returned 49 - 50 - ### Tests 51 - 52 - - [x] Unit tests: repository method, cubit state transitions 53 - - [x] Widget tests: sheet renders profiles, follow button toggles, own-profile menu hides entry, empty state 14 + Completed [2026-04-01](../../CHANGELOG.md#2026-04-01) 54 15 55 16 ## M22 - Video Upload Limits 56 17 57 - ### Core 58 - 59 - - [x] `VideoRepository` (or extend settings repository) - `getUploadLimits()` calling `bluesky.video.getUploadLimits()`, return typed result 60 - 61 - ### Cubit 62 - 63 - - [x] `VideoUploadLimitsCubit` - fetch on init, expose `canUpload`, remaining counts, message/error 64 - 65 - ### UI 66 - 67 - - [x] Settings screen - new tile in Account section: "Video Upload Limits" 68 - - [x] Tile UI - show remaining daily video count, remaining bytes formatted as MB/GB, `canUpload` status badge 69 - - [x] Loading state while fetching, error state if request fails 70 - - [x] Display server `message` if present; show `error` text with warning styling if `canUpload` is false 71 - 72 - ### Tests 73 - 74 - - [x] Unit tests: repository method, cubit state transitions and formatting 75 - - [x] Widget tests: tile renders limits, loading indicator, error state, message display 18 + Completed [2026-04-01](../../CHANGELOG.md#2026-04-01) 76 19 77 20 ## M23 - Profile Context (Constellation) 78 21
+28 -36
lib/features/search/bloc/search_bloc.dart
··· 71 71 ).copyWith(searchHistory: history), 72 72 ); 73 73 } catch (error) { 74 - emit(SearchState.error(query: query, message: 'Failed to search posts: $error')); 74 + emit( 75 + SearchState.error( 76 + query: query, 77 + message: 'Failed to search posts: $error', 78 + tab: currentTab, 79 + sort: currentSort, 80 + ), 81 + ); 75 82 } 76 83 } else if (currentTab == SearchTab.actors) { 77 84 emit(SearchState.loadingActors(query: query)); ··· 89 96 ).copyWith(searchHistory: history), 90 97 ); 91 98 } catch (error) { 92 - emit(SearchState.error(query: query, message: 'Failed to search actors: $error')); 99 + emit( 100 + SearchState.error( 101 + query: query, 102 + message: 'Failed to search actors: $error', 103 + tab: currentTab, 104 + sort: currentSort, 105 + ), 106 + ); 93 107 } 94 108 } else if (currentTab == SearchTab.feeds) { 95 109 emit(SearchState.loadingFeeds(query: query)); ··· 107 121 ).copyWith(searchHistory: history), 108 122 ); 109 123 } catch (error) { 110 - emit(SearchState.error(query: query, message: 'Failed to search feeds: $error')); 111 - } 112 - } else { 113 - emit(SearchState.loadingStarterPacks(query: query)); 114 - 115 - try { 116 - final result = await _searchRepository.searchStarterPacks(query: query, limit: 25); 117 - 118 124 emit( 119 - SearchState.loadedStarterPacks( 125 + SearchState.error( 120 126 query: query, 121 - starterPacks: result.starterPacks, 122 - starterPacksCursor: result.cursor, 127 + message: 'Failed to search feeds: $error', 128 + tab: currentTab, 129 + sort: currentSort, 123 130 ), 124 131 ); 125 - } catch (error) { 126 - emit(SearchState.error(query: query, message: 'Failed to search starter packs: $error')); 127 132 } 133 + } else { 134 + emit( 135 + SearchState.loadedStarterPacks( 136 + query: query, 137 + starterPacks: const [], 138 + starterPacksCursor: null, 139 + ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 140 + ); 128 141 } 129 142 } 130 143 ··· 152 165 if (state.isLoadingMore) return; 153 166 154 167 if (state.currentTab == SearchTab.starterPacks) { 155 - if (state.starterPacksCursor == null) return; 156 - 157 - emit(state.copyWith(isLoadingMore: true)); 158 - 159 - try { 160 - final result = await _searchRepository.searchStarterPacks( 161 - query: state.query, 162 - cursor: state.starterPacksCursor, 163 - limit: 25, 164 - ); 165 - 166 - emit( 167 - SearchState.loadedStarterPacks( 168 - query: state.query, 169 - starterPacks: [...state.starterPacks, ...result.starterPacks], 170 - starterPacksCursor: result.cursor, 171 - ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 172 - ); 173 - } catch (error) { 174 - emit(state.copyWith(isLoadingMore: false)); 175 - } 176 168 return; 177 169 } 178 170
+2 -2
lib/features/search/bloc/search_state.dart
··· 96 96 starterPacksCursor: starterPacksCursor, 97 97 ); 98 98 99 - const SearchState.error({required String query, required String message}) 100 - : this._(status: SearchStatus.error, query: query, errorMessage: message); 99 + const SearchState.error({required String query, required String message, required SearchTab tab, String sort = 'top'}) 100 + : this._(status: SearchStatus.error, query: query, currentTab: tab, currentSort: sort, errorMessage: message); 101 101 102 102 static const Object _unset = Object(); 103 103
+31 -4
lib/features/search/data/search_repository.dart
··· 3 3 import 'package:bluesky/app_bsky_feed_searchposts.dart'; 4 4 import 'package:bluesky/app_bsky_graph_defs.dart'; 5 5 import 'package:bluesky/bluesky.dart'; 6 + import 'package:lazurite/core/network/app_view_provider.dart'; 6 7 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 7 8 8 9 class SearchRepository { 9 - SearchRepository({required Bluesky bluesky, ModerationService? moderationService}) 10 - : _bluesky = bluesky, 11 - _moderationService = moderationService; 10 + SearchRepository({ 11 + required Bluesky bluesky, 12 + ModerationService? moderationService, 13 + String? appViewProvider, 14 + String Function()? appViewProviderResolver, 15 + }) : _bluesky = bluesky, 16 + _moderationService = moderationService, 17 + _appViewProvider = AppViewProviders.normalizeSettingKey(appViewProvider), 18 + _appViewProviderResolver = appViewProviderResolver; 12 19 13 20 final Bluesky _bluesky; 14 21 final ModerationService? _moderationService; 22 + final String _appViewProvider; 23 + final String Function()? _appViewProviderResolver; 15 24 16 25 Future<SearchPostsResult> searchPosts({ 17 26 required String query, ··· 50 59 } 51 60 52 61 Future<SearchStarterPacksResult> searchStarterPacks({required String query, String? cursor, int limit = 25}) async { 53 - final response = await _bluesky.graph.searchStarterPacks(q: query, cursor: cursor, limit: limit); 62 + final appViewDescriptor = AppViewProviders.descriptorForSetting(_resolveAppViewProvider()); 63 + final response = await _bluesky.graph.searchStarterPacks( 64 + q: query, 65 + cursor: cursor, 66 + limit: limit, 67 + $service: appViewDescriptor.publicBaseUrl.host, 68 + $headers: await _moderationService?.headersForRequest(), 69 + ); 54 70 55 71 return SearchStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); 56 72 } 57 73 58 74 Future<SearchFeedsResult> searchFeedGenerators({required String query, String? cursor, int limit = 25}) async { 75 + final appViewDescriptor = AppViewProviders.descriptorForSetting(_resolveAppViewProvider()); 59 76 final response = await _bluesky.unspecced.getPopularFeedGenerators( 60 77 query: query, 61 78 cursor: cursor, 62 79 limit: limit, 80 + $service: appViewDescriptor.publicBaseUrl.host, 63 81 $headers: await _moderationService?.headersForRequest(), 64 82 ); 65 83 ··· 101 119 } 102 120 103 121 return profiles.where((profile) => !moderationService.shouldFilterProfileBasicInList(profile)).toList(); 122 + } 123 + 124 + String _resolveAppViewProvider() { 125 + final resolver = _appViewProviderResolver; 126 + if (resolver == null) { 127 + return _appViewProvider; 128 + } 129 + 130 + return AppViewProviders.normalizeSettingKey(resolver.call()); 104 131 } 105 132 } 106 133
+131 -10
lib/features/search/presentation/search_screen.dart
··· 27 27 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 28 28 import 'package:lazurite/shared/utils/format_utils.dart'; 29 29 import 'package:lazurite/core/theme/theme_extensions.dart'; 30 + import 'package:url_launcher/url_launcher.dart'; 30 31 31 32 class SearchScreen extends StatefulWidget { 32 33 const SearchScreen({super.key}); ··· 36 37 } 37 38 38 39 class _SearchScreenState extends State<SearchScreen> { 40 + static final Uri _starterPackSearchIssueUri = Uri.parse('https://github.com/bluesky-social/bsky-docs/issues/306'); 41 + 39 42 final TextEditingController _searchController = TextEditingController(); 40 43 final FocusNode _focusNode = FocusNode(); 41 44 final ScrollController _scrollController = ScrollController(); ··· 69 72 70 73 void _onSubmit(String query) { 71 74 if (query.trim().isEmpty) return; 75 + if (context.read<SearchBloc>().state.currentTab == SearchTab.starterPacks) { 76 + _focusNode.unfocus(); 77 + return; 78 + } 72 79 context.read<SearchBloc>().add(QuerySubmitted(query: query)); 73 80 _focusNode.unfocus(); 74 81 } ··· 238 245 onSubmitted: _onSubmit, 239 246 textInputAction: TextInputAction.search, 240 247 decoration: InputDecoration( 241 - hintText: 'Search posts or people', 248 + hintText: _searchPlaceholderForTab(state.currentTab), 242 249 prefixIcon: const Icon(Icons.search, size: 20), 243 250 suffixIcon: hasText ? _buildSuffixIcon(context, theme) : null, 244 251 border: OutlineInputBorder(borderRadius: BorderRadius.circular(999)), ··· 381 388 } 382 389 383 390 Widget _buildBody(BuildContext context, SearchState state) { 391 + if (state.currentTab == SearchTab.starterPacks) { 392 + return _buildStarterPacksUnavailableState(context); 393 + } 394 + 384 395 if (state.query.isEmpty) { 385 396 return _buildSearchHistory(context, state); 386 397 } ··· 425 436 Widget _buildSearchHistory(BuildContext context, SearchState state) { 426 437 final history = state.searchHistory; 427 438 if (history.isEmpty) { 428 - return const _SearchEmptyState(); 439 + return _SearchEmptyState(tab: state.currentTab); 429 440 } 430 441 431 442 return Column( ··· 478 489 Widget _buildPostResults(BuildContext context, SearchState state) { 479 490 final posts = state.posts; 480 491 if (posts.isEmpty) { 481 - return Center(child: Text('No posts found', style: context.textTheme.bodyLarge)); 492 + return _SearchNoResultsState(tab: SearchTab.posts, query: state.query); 482 493 } 483 494 484 495 return ListView.builder( ··· 504 515 Widget _buildActorResults(BuildContext context, SearchState state) { 505 516 final actors = state.actors; 506 517 if (actors.isEmpty) { 507 - return Center(child: Text('No people found', style: context.textTheme.bodyLarge)); 518 + return _SearchNoResultsState(tab: SearchTab.actors, query: state.query); 508 519 } 509 520 510 521 return ListView.builder( ··· 530 541 Widget _buildFeedResults(BuildContext context, SearchState state) { 531 542 final feeds = state.feeds; 532 543 if (feeds.isEmpty) { 533 - return Center(child: Text('No feeds found', style: context.textTheme.bodyLarge)); 544 + return _SearchNoResultsState(tab: SearchTab.feeds, query: state.query); 534 545 } 535 546 536 547 return ListView.builder( ··· 567 578 Widget _buildStarterPackResults(BuildContext context, SearchState state) { 568 579 final packs = state.starterPacks; 569 580 if (packs.isEmpty) { 570 - return Center(child: Text('No starter packs found', style: context.textTheme.bodyLarge)); 581 + return _SearchNoResultsState(tab: SearchTab.starterPacks, query: state.query); 571 582 } 572 583 573 584 return ListView.builder( ··· 601 612 String _formatHistoryTime(DateTime time) { 602 613 return formatRelativeTime(time, nowLabel: 'Just now', includeAgo: true); 603 614 } 615 + 616 + String _searchPlaceholderForTab(SearchTab tab) => switch (tab) { 617 + SearchTab.posts => 'Search posts', 618 + SearchTab.actors => 'Search people', 619 + SearchTab.feeds => 'Search feeds', 620 + SearchTab.starterPacks => 'Starter pack search unavailable', 621 + }; 622 + 623 + Widget _buildStarterPacksUnavailableState(BuildContext context) { 624 + return Center( 625 + child: Padding( 626 + padding: const EdgeInsets.all(24), 627 + child: Column( 628 + mainAxisSize: MainAxisSize.min, 629 + children: [ 630 + Icon(Icons.info_outline, size: 52, color: context.colorScheme.outline), 631 + const SizedBox(height: 16), 632 + Text( 633 + 'Starter Pack Search Is Unavailable', 634 + style: context.textTheme.titleMedium, 635 + textAlign: TextAlign.center, 636 + ), 637 + const SizedBox(height: 8), 638 + Text( 639 + '(Starter Pack Search is not yet implemented in the BlueSky API)', 640 + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), 641 + textAlign: TextAlign.center, 642 + ), 643 + const SizedBox(height: 14), 644 + TextButton.icon( 645 + onPressed: _openStarterPackIssue, 646 + icon: const Icon(Icons.open_in_new), 647 + label: const Text('Track API progress'), 648 + ), 649 + ], 650 + ), 651 + ), 652 + ); 653 + } 654 + 655 + Future<void> _openStarterPackIssue() async { 656 + final launched = await launchUrl(_starterPackSearchIssueUri, mode: LaunchMode.externalApplication); 657 + if (!launched && mounted) { 658 + showAppSnackBar(context, 'Could not open issue link.'); 659 + } 660 + } 604 661 } 605 662 606 663 class _PostViewCard extends StatelessWidget { ··· 953 1010 } 954 1011 955 1012 class _SearchEmptyState extends StatelessWidget { 956 - const _SearchEmptyState(); 1013 + const _SearchEmptyState({required this.tab}); 1014 + 1015 + final SearchTab tab; 957 1016 958 1017 @override 959 1018 Widget build(BuildContext context) { 1019 + final (title, message) = switch (tab) { 1020 + SearchTab.posts => ( 1021 + 'Search posts', 1022 + 'Find conversations and keywords across posts.\nUse Jump to profile to quickly open a user.', 1023 + ), 1024 + SearchTab.actors => ( 1025 + 'Search people', 1026 + 'Look up accounts by handle or name.\nUse Jump to profile when you know the exact handle.', 1027 + ), 1028 + SearchTab.feeds => ('Search feeds', 'Discover custom feeds by topic, creator, or keyword.'), 1029 + SearchTab.starterPacks => ('Search starter packs', 'Find curated starter packs to discover accounts and feeds.'), 1030 + }; 1031 + 960 1032 return Center( 961 1033 child: Padding( 962 1034 padding: const EdgeInsets.all(24), ··· 965 1037 children: [ 966 1038 Icon(Icons.search, size: 64, color: context.colorScheme.outline), 967 1039 const SizedBox(height: 16), 968 - Text('Search', style: context.textTheme.titleMedium), 1040 + Text(title, style: context.textTheme.titleMedium), 969 1041 const SizedBox(height: 8), 970 1042 Text( 971 - 'Find posts and people on the network.\n' 972 - 'Use Jump to profile to quickly open a user.', 1043 + message, 973 1044 textAlign: TextAlign.center, 974 1045 style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.outline), 975 1046 ), ··· 979 1050 ); 980 1051 } 981 1052 } 1053 + 1054 + class _SearchNoResultsState extends StatelessWidget { 1055 + const _SearchNoResultsState({required this.tab, required this.query}); 1056 + 1057 + final SearchTab tab; 1058 + final String query; 1059 + 1060 + @override 1061 + Widget build(BuildContext context) { 1062 + final safeQuery = query.trim(); 1063 + final (title, message) = switch (tab) { 1064 + SearchTab.posts => ( 1065 + 'No posts found', 1066 + 'Try broader keywords or a shorter phrase${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1067 + ), 1068 + SearchTab.actors => ( 1069 + 'No people found', 1070 + 'Try a handle, display name, or fewer terms${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1071 + ), 1072 + SearchTab.feeds => ( 1073 + 'No feeds found', 1074 + 'Try searching by topic or feed creator${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1075 + ), 1076 + SearchTab.starterPacks => ( 1077 + 'No starter packs found', 1078 + 'Try another topic or broader keyword${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1079 + ), 1080 + }; 1081 + 1082 + return Center( 1083 + child: Padding( 1084 + padding: const EdgeInsets.symmetric(horizontal: 24), 1085 + child: Column( 1086 + mainAxisSize: MainAxisSize.min, 1087 + children: [ 1088 + Icon(Icons.search_off_rounded, size: 44, color: context.colorScheme.outline), 1089 + const SizedBox(height: 12), 1090 + Text(title, style: context.textTheme.bodyLarge), 1091 + const SizedBox(height: 6), 1092 + Text( 1093 + message, 1094 + textAlign: TextAlign.center, 1095 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 1096 + ), 1097 + ], 1098 + ), 1099 + ), 1100 + ); 1101 + } 1102 + }
+8 -2
lib/main.dart
··· 268 268 ), 269 269 ), 270 270 RepositoryProvider( 271 - create: (context) => 272 - SearchRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 271 + create: (context) { 272 + final settingsCubit = context.read<SettingsCubit>(); 273 + return SearchRepository( 274 + bluesky: bluesky, 275 + moderationService: context.read<ModerationService>(), 276 + appViewProviderResolver: () => settingsCubit.state.appViewProvider, 277 + ); 278 + }, 273 279 ), 274 280 RepositoryProvider( 275 281 create: (context) {
+37 -85
test/features/search/bloc/search_bloc_test.dart
··· 187 187 188 188 group('QuerySubmitted - starterPacks tab', () { 189 189 blocTest<SearchBloc, SearchState>( 190 - 'searches starter packs when starterPacks tab is active', 190 + 'does not call starter packs API when starterPacks tab is active', 191 191 build: buildBloc, 192 192 seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.starterPacks), 193 - setUp: () { 194 - when( 195 - () => mockRepository.searchStarterPacks( 196 - query: 'flutter', 197 - cursor: any(named: 'cursor'), 198 - limit: any(named: 'limit'), 199 - ), 200 - ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [sampleStarterPack], cursor: 'c1')); 201 - }, 202 193 act: (bloc) => bloc.add(const QuerySubmitted(query: 'flutter')), 203 194 expect: () => [ 204 195 predicate<SearchState>( 205 - (s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.starterPacks && s.query == 'flutter', 206 - ), 207 - predicate<SearchState>( 208 196 (s) => 209 197 s.status == SearchStatus.loaded && 210 198 s.currentTab == SearchTab.starterPacks && 211 - s.starterPacks.length == 1 && 212 - s.starterPacksCursor == 'c1', 199 + s.starterPacks.isEmpty && 200 + s.starterPacksCursor == null && 201 + s.query == 'flutter', 213 202 ), 214 203 ], 215 - ); 216 - 217 - blocTest<SearchBloc, SearchState>( 218 - 'emits error when starterPacks search fails', 219 - build: buildBloc, 220 - seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.starterPacks), 221 - setUp: () { 222 - when( 204 + verify: (_) { 205 + verifyNever( 223 206 () => mockRepository.searchStarterPacks( 224 207 query: any(named: 'query'), 225 208 cursor: any(named: 'cursor'), 226 209 limit: any(named: 'limit'), 227 210 ), 228 - ).thenThrow(Exception('network error')); 211 + ); 229 212 }, 213 + ); 214 + 215 + blocTest<SearchBloc, SearchState>( 216 + 'never enters error state for starterPacks searches', 217 + build: buildBloc, 218 + seed: () => const SearchState.initial().copyWith(currentTab: SearchTab.starterPacks), 230 219 act: (bloc) => bloc.add(const QuerySubmitted(query: 'fail')), 231 220 expect: () => [ 232 - predicate<SearchState>((s) => s.status == SearchStatus.loading), 233 - predicate<SearchState>((s) => s.status == SearchStatus.error && s.errorMessage != null), 221 + predicate<SearchState>((s) => s.status == SearchStatus.loaded && s.currentTab == SearchTab.starterPacks), 234 222 ], 235 223 ); 236 224 ··· 299 287 act: (bloc) => bloc.add(const QuerySubmitted(query: 'fail feed')), 300 288 expect: () => [ 301 289 predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.feeds), 302 - predicate<SearchState>((s) => s.status == SearchStatus.error && s.errorMessage != null), 290 + predicate<SearchState>( 291 + (s) => s.status == SearchStatus.error && s.errorMessage != null && s.currentTab == SearchTab.feeds, 292 + ), 303 293 ], 304 294 ); 305 295 }); 306 296 307 297 group('SearchTabChanged', () { 308 298 blocTest<SearchBloc, SearchState>( 309 - 'switches to starterPacks tab and re-searches when query present', 299 + 'switches to starterPacks tab without calling starter packs API', 310 300 build: buildBloc, 311 301 seed: () => SearchState.loadedPosts(query: 'flutter', sort: 'top', posts: [samplePost], cursor: null), 312 - setUp: () { 313 - when( 302 + act: (bloc) => bloc.add(const SearchTabChanged(tab: SearchTab.starterPacks)), 303 + expect: () => [ 304 + predicate<SearchState>((s) => s.currentTab == SearchTab.starterPacks), 305 + predicate<SearchState>( 306 + (s) => s.status == SearchStatus.loaded && s.currentTab == SearchTab.starterPacks && s.starterPacks.isEmpty, 307 + ), 308 + ], 309 + verify: (_) { 310 + verifyNever( 314 311 () => mockRepository.searchStarterPacks( 315 - query: 'flutter', 312 + query: any(named: 'query'), 316 313 cursor: any(named: 'cursor'), 317 314 limit: any(named: 'limit'), 318 315 ), 319 - ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [sampleStarterPack])); 316 + ); 320 317 }, 321 - act: (bloc) => bloc.add(const SearchTabChanged(tab: SearchTab.starterPacks)), 322 - expect: () => [ 323 - predicate<SearchState>((s) => s.currentTab == SearchTab.starterPacks), 324 - predicate<SearchState>((s) => s.status == SearchStatus.loading && s.currentTab == SearchTab.starterPacks), 325 - predicate<SearchState>((s) => s.status == SearchStatus.loaded && s.starterPacks.length == 1), 326 - ], 327 318 ); 328 319 329 320 blocTest<SearchBloc, SearchState>( ··· 351 342 352 343 group('LoadMoreRequested - starterPacks tab', () { 353 344 blocTest<SearchBloc, SearchState>( 354 - 'loads more starter packs using starterPacksCursor', 345 + 'does nothing on load more for starterPacks tab', 355 346 build: buildBloc, 356 347 seed: () => SearchState.loadedStarterPacks( 357 348 query: 'flutter', 358 349 starterPacks: [sampleStarterPack], 359 350 starterPacksCursor: 'cursor-page-1', 360 351 ), 361 - setUp: () { 362 - final secondPack = StarterPackViewBasic( 363 - uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-2'), 364 - cid: 'cid-pack-2', 365 - record: const { 366 - r'$type': 'app.bsky.graph.starterpack', 367 - 'name': 'Another Pack', 368 - 'list': 'at://did:plc:creator/app.bsky.graph.list/list-2', 369 - 'createdAt': '2026-01-01T00:00:00.000Z', 370 - }, 371 - creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 372 - indexedAt: DateTime.utc(2026, 1, 1), 373 - ); 374 - when( 352 + act: (bloc) => bloc.add(const LoadMoreRequested()), 353 + expect: () => [], 354 + verify: (_) { 355 + verifyNever( 375 356 () => mockRepository.searchStarterPacks( 376 - query: 'flutter', 377 - cursor: 'cursor-page-1', 357 + query: any(named: 'query'), 358 + cursor: any(named: 'cursor'), 378 359 limit: any(named: 'limit'), 379 360 ), 380 - ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [secondPack], cursor: null)); 361 + ); 381 362 }, 382 - act: (bloc) => bloc.add(const LoadMoreRequested()), 383 - expect: () => [ 384 - predicate<SearchState>((s) => s.isLoadingMore), 385 - predicate<SearchState>((s) => !s.isLoadingMore && s.starterPacks.length == 2 && s.starterPacksCursor == null), 386 - ], 387 363 ); 388 364 389 365 blocTest<SearchBloc, SearchState>( ··· 393 369 SearchState.loadedStarterPacks(query: 'flutter', starterPacks: [sampleStarterPack], starterPacksCursor: null), 394 370 act: (bloc) => bloc.add(const LoadMoreRequested()), 395 371 expect: () => [], 396 - ); 397 - 398 - blocTest<SearchBloc, SearchState>( 399 - 'handles load more error gracefully', 400 - build: buildBloc, 401 - seed: () => SearchState.loadedStarterPacks( 402 - query: 'flutter', 403 - starterPacks: [sampleStarterPack], 404 - starterPacksCursor: 'cursor-1', 405 - ), 406 - setUp: () { 407 - when( 408 - () => mockRepository.searchStarterPacks( 409 - query: any(named: 'query'), 410 - cursor: any(named: 'cursor'), 411 - limit: any(named: 'limit'), 412 - ), 413 - ).thenThrow(Exception('network error')); 414 - }, 415 - act: (bloc) => bloc.add(const LoadMoreRequested()), 416 - expect: () => [ 417 - predicate<SearchState>((s) => s.isLoadingMore), 418 - predicate<SearchState>((s) => !s.isLoadingMore && s.starterPacks.length == 1), 419 - ], 420 372 ); 421 373 }); 422 374
+42 -134
test/features/search/presentation/search_screen_test.dart
··· 2 2 import 'package:bloc_test/bloc_test.dart'; 3 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 - import 'package:bluesky/app_bsky_graph_defs.dart'; 6 5 import 'package:flutter/material.dart'; 7 6 import 'package:flutter_bloc/flutter_bloc.dart'; 8 7 import 'package:flutter_test/flutter_test.dart'; ··· 165 164 await tester.pumpWidget(buildSubject()); 166 165 await tester.pumpAndSettle(); 167 166 168 - expect(find.text('Search posts or people'), findsOneWidget); 167 + final searchField = tester.widget<TextField>(find.byType(TextField).first); 168 + expect(searchField.decoration?.hintText, 'Search posts'); 169 169 expect(find.text('Posts'), findsOneWidget); 170 170 expect(find.text('People'), findsOneWidget); 171 171 expect(find.text('Feeds'), findsOneWidget); ··· 187 187 expect(starterPackLabel.softWrap, isFalse); 188 188 }); 189 189 190 - testWidgets('shows empty state when no search history', (tester) async { 190 + testWidgets('shows tab-aware empty state when no search history', (tester) async { 191 191 await tester.pumpWidget(buildSubject()); 192 192 await tester.pumpAndSettle(); 193 193 194 - expect(find.text('Search'), findsOneWidget); 195 - expect(find.textContaining('Find posts and people'), findsOneWidget); 194 + expect(find.text('Search posts'), findsWidgets); 195 + expect(find.textContaining('Find conversations and keywords across posts'), findsOneWidget); 196 + }); 197 + 198 + testWidgets('updates placeholder and empty-state copy when changing tabs', (tester) async { 199 + await tester.pumpWidget(buildSubject()); 200 + await tester.pumpAndSettle(); 201 + 202 + expect(find.text('Search posts'), findsWidgets); 203 + 204 + await tester.tap(find.text('People')); 205 + await tester.pumpAndSettle(); 206 + expect(find.text('Search people'), findsWidgets); 207 + expect(find.textContaining('Look up accounts by handle or name'), findsOneWidget); 208 + 209 + await tester.tap(find.text('Feeds')); 210 + await tester.pumpAndSettle(); 211 + expect(find.text('Search feeds'), findsWidgets); 212 + expect(find.textContaining('Discover custom feeds by topic'), findsOneWidget); 213 + 214 + await tester.tap(find.text('Starter Packs')); 215 + await tester.pumpAndSettle(); 216 + final searchField = tester.widget<TextField>(find.byType(TextField).first); 217 + expect(searchField.decoration?.hintText, 'Starter pack search unavailable'); 218 + expect(find.text('Starter Pack Search Is Unavailable'), findsOneWidget); 219 + expect(find.textContaining('not yet implemented in the BlueSky API'), findsOneWidget); 196 220 }); 197 221 198 222 testWidgets('tab switching works correctly', (tester) async { ··· 425 449 expect(find.text('Starter Packs'), findsOneWidget); 426 450 }); 427 451 428 - testWidgets('switching to Starter Packs tab shows empty state when no results', (tester) async { 429 - when( 430 - () => mockDatabase.addSearchHistoryEntry( 431 - query: any(named: 'query'), 432 - type: any(named: 'type'), 433 - accountDid: any(named: 'accountDid'), 434 - ), 435 - ).thenAnswer((_) async {}); 436 - 452 + testWidgets('starter packs tab shows unavailable message and does not search', (tester) async { 437 453 await tester.pumpWidget(buildSubject()); 438 454 await tester.pumpAndSettle(); 439 455 ··· 445 461 await tester.testTextInput.receiveAction(TextInputAction.search); 446 462 await tester.pumpAndSettle(); 447 463 448 - expect(find.text('No starter packs found'), findsOneWidget); 464 + expect(find.text('Starter Pack Search Is Unavailable'), findsOneWidget); 465 + expect(find.text('Track API progress'), findsOneWidget); 466 + verifyNever( 467 + () => mockSearchRepository.searchStarterPacks( 468 + query: any(named: 'query'), 469 + cursor: any(named: 'cursor'), 470 + limit: any(named: 'limit'), 471 + ), 472 + ); 449 473 }); 450 474 451 475 testWidgets('feed tab shows search results and adds a feed with snackbar action', (tester) async { ··· 552 576 expect(find.text('fallback-name'), findsOneWidget); 553 577 }); 554 578 555 - testWidgets('starter pack results display name and creator handle', (tester) async { 556 - final samplePack = StarterPackViewBasic( 557 - uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), 558 - cid: 'cid-pack-1', 559 - record: const { 560 - r'$type': 'app.bsky.graph.starterpack', 561 - 'name': 'My Starter Pack', 562 - 'list': 'at://did:plc:creator/app.bsky.graph.list/list-1', 563 - 'createdAt': '2026-01-01T00:00:00.000Z', 564 - }, 565 - creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 566 - listItemCount: 10, 567 - joinedWeekCount: 3, 568 - joinedAllTimeCount: 42, 569 - indexedAt: DateTime.utc(2026, 1, 1), 570 - ); 571 - 572 - when( 573 - () => mockSearchRepository.searchStarterPacks( 574 - query: any(named: 'query'), 575 - cursor: any(named: 'cursor'), 576 - limit: any(named: 'limit'), 577 - ), 578 - ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [samplePack])); 579 - 580 - when( 581 - () => mockDatabase.addSearchHistoryEntry( 582 - query: any(named: 'query'), 583 - type: any(named: 'type'), 584 - accountDid: any(named: 'accountDid'), 585 - ), 586 - ).thenAnswer((_) async {}); 587 - 579 + testWidgets('starter packs tab shows Bluesky issue link text', (tester) async { 588 580 await tester.pumpWidget(buildSubject()); 589 581 await tester.pumpAndSettle(); 590 582 591 583 await tester.tap(find.text('Starter Packs')); 592 584 await tester.pumpAndSettle(); 593 585 594 - final searchField = find.byType(TextField); 595 - await tester.enterText(searchField, 'starter'); 596 - await tester.testTextInput.receiveAction(TextInputAction.search); 597 - await tester.pumpAndSettle(); 598 - 599 - expect(find.text('My Starter Pack'), findsOneWidget); 600 - expect(find.text('by @creator.bsky.social'), findsOneWidget); 601 - expect(find.text('10'), findsOneWidget); 602 - }); 603 - 604 - testWidgets('tapping starter pack result navigates to starter pack detail', (tester) async { 605 - final packUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); 606 - final samplePack = StarterPackViewBasic( 607 - uri: packUri, 608 - cid: 'cid-pack-1', 609 - record: const { 610 - r'$type': 'app.bsky.graph.starterpack', 611 - 'name': 'My Starter Pack', 612 - 'list': 'at://did:plc:creator/app.bsky.graph.list/list-1', 613 - 'createdAt': '2026-01-01T00:00:00.000Z', 614 - }, 615 - creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 616 - indexedAt: DateTime.utc(2026, 1, 1), 617 - ); 618 - 619 - when( 620 - () => mockSearchRepository.searchStarterPacks( 621 - query: any(named: 'query'), 622 - cursor: any(named: 'cursor'), 623 - limit: any(named: 'limit'), 624 - ), 625 - ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [samplePack])); 626 - 627 - when( 628 - () => mockDatabase.addSearchHistoryEntry( 629 - query: any(named: 'query'), 630 - type: any(named: 'type'), 631 - accountDid: any(named: 'accountDid'), 632 - ), 633 - ).thenAnswer((_) async {}); 634 - 635 - final router = GoRouter( 636 - routes: [ 637 - GoRoute( 638 - path: '/', 639 - builder: (context, state) => RepositoryProvider<TypeaheadRepository>.value( 640 - value: mockTypeaheadRepository, 641 - child: BlocProvider<SearchBloc>( 642 - create: (_) => SearchBloc( 643 - searchRepository: mockSearchRepository, 644 - typeaheadRepository: mockTypeaheadRepository, 645 - database: mockDatabase, 646 - accountDid: 'did:plc:test', 647 - ), 648 - child: MultiBlocProvider( 649 - providers: [ 650 - BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 651 - BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 652 - ], 653 - child: const SearchScreen(), 654 - ), 655 - ), 656 - ), 657 - ), 658 - GoRoute( 659 - path: '/starter-pack', 660 - builder: (context, state) => Scaffold(body: Text('starterpack:${state.uri.queryParameters['uri']}')), 661 - ), 662 - ], 663 - ); 664 - 665 - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 666 - await tester.pumpAndSettle(); 667 - 668 - await tester.tap(find.text('Starter Packs')); 669 - await tester.pumpAndSettle(); 670 - 671 - final searchField = find.byType(TextField); 672 - await tester.enterText(searchField, 'starter'); 673 - await tester.testTextInput.receiveAction(TextInputAction.search); 674 - await tester.pumpAndSettle(); 675 - 676 - await tester.tap(find.text('My Starter Pack')); 677 - await tester.pumpAndSettle(); 678 - 679 - expect(find.text('starterpack:${packUri.toString()}'), findsOneWidget); 586 + expect(find.text('Track API progress'), findsOneWidget); 587 + expect(find.textContaining('https://github.com/bluesky-social/bsky-docs/issues/306'), findsNothing); 680 588 }); 681 589 }); 682 590 }