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: starter pack search UI

+197 -2
+39 -1
lib/features/search/presentation/search_screen.dart
··· 15 15 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 16 16 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 17 17 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 18 + import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 18 19 19 20 class SearchScreen extends StatefulWidget { 20 21 const SearchScreen({super.key}); ··· 315 316 decoration: BoxDecoration( 316 317 border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 317 318 ), 318 - child: Row(children: [_buildTab(context, SearchTab.posts, state), _buildTab(context, SearchTab.actors, state)]), 319 + child: Row( 320 + children: [ 321 + _buildTab(context, SearchTab.posts, state), 322 + _buildTab(context, SearchTab.actors, state), 323 + _buildTab(context, SearchTab.starterPacks, state), 324 + ], 325 + ), 319 326 ); 320 327 } 321 328 ··· 430 437 431 438 if (state.currentTab == SearchTab.posts) { 432 439 return _buildPostResults(context, state); 440 + } else if (state.currentTab == SearchTab.starterPacks) { 441 + return _buildStarterPackResults(context, state); 433 442 } else { 434 443 return _buildActorResults(context, state); 435 444 } ··· 535 544 ); 536 545 } 537 546 return _ActorResultTile(actor: actors[index]); 547 + }, 548 + ); 549 + } 550 + 551 + Widget _buildStarterPackResults(BuildContext context, SearchState state) { 552 + final packs = state.starterPacks; 553 + if (packs.isEmpty) { 554 + return Center(child: Text('No starter packs found', style: Theme.of(context).textTheme.bodyLarge)); 555 + } 556 + 557 + return ListView.builder( 558 + controller: _scrollController, 559 + itemCount: packs.length + (state.isLoadingMore ? 1 : 0), 560 + itemBuilder: (context, index) { 561 + if (index == packs.length) { 562 + return const Center( 563 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 564 + ); 565 + } 566 + final pack = packs[index]; 567 + return StarterPackCard( 568 + pack: pack, 569 + onTap: () { 570 + final router = GoRouter.maybeOf(context); 571 + if (router != null) { 572 + router.push('/starter-pack?uri=${Uri.encodeComponent(pack.uri.toString())}'); 573 + } 574 + }, 575 + ); 538 576 }, 539 577 ); 540 578 }
+158 -1
test/features/search/presentation/search_screen_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bloc_test/bloc_test.dart'; 2 3 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 - import 'package:bloc_test/bloc_test.dart'; 5 + import 'package:bluesky/app_bsky_graph_defs.dart'; 5 6 import 'package:flutter/material.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:flutter_test/flutter_test.dart'; ··· 57 58 limit: any(named: 'limit'), 58 59 ), 59 60 ).thenAnswer((_) async => []); 61 + when( 62 + () => mockSearchRepository.searchStarterPacks( 63 + query: any(named: 'query'), 64 + cursor: any(named: 'cursor'), 65 + limit: any(named: 'limit'), 66 + ), 67 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [])); 60 68 }); 61 69 62 70 Widget buildSubject() { ··· 298 306 await tester.pumpAndSettle(); 299 307 300 308 expect(find.text('profile:custom.bsky.social'), findsOneWidget); 309 + }); 310 + 311 + testWidgets('third Starter Packs tab renders', (tester) async { 312 + await tester.pumpWidget(buildSubject()); 313 + await tester.pumpAndSettle(); 314 + 315 + expect(find.text('Starter Packs'), findsOneWidget); 316 + }); 317 + 318 + testWidgets('switching to Starter Packs tab shows empty state when no results', (tester) async { 319 + when( 320 + () => mockDatabase.addSearchHistoryEntry( 321 + query: any(named: 'query'), 322 + type: any(named: 'type'), 323 + accountDid: any(named: 'accountDid'), 324 + ), 325 + ).thenAnswer((_) async {}); 326 + 327 + await tester.pumpWidget(buildSubject()); 328 + await tester.pumpAndSettle(); 329 + 330 + await tester.tap(find.text('Starter Packs')); 331 + await tester.pumpAndSettle(); 332 + 333 + final searchField = find.byType(TextField); 334 + await tester.enterText(searchField, 'starter'); 335 + await tester.testTextInput.receiveAction(TextInputAction.search); 336 + await tester.pumpAndSettle(); 337 + 338 + expect(find.text('No starter packs found'), findsOneWidget); 339 + }); 340 + 341 + testWidgets('starter pack results display name and creator handle', (tester) async { 342 + final samplePack = StarterPackViewBasic( 343 + uri: AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'), 344 + cid: 'cid-pack-1', 345 + record: const { 346 + r'$type': 'app.bsky.graph.starterpack', 347 + 'name': 'My Starter Pack', 348 + 'list': 'at://did:plc:creator/app.bsky.graph.list/list-1', 349 + 'createdAt': '2026-01-01T00:00:00.000Z', 350 + }, 351 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 352 + listItemCount: 10, 353 + joinedWeekCount: 3, 354 + joinedAllTimeCount: 42, 355 + indexedAt: DateTime.utc(2026, 1, 1), 356 + ); 357 + 358 + when( 359 + () => mockSearchRepository.searchStarterPacks( 360 + query: any(named: 'query'), 361 + cursor: any(named: 'cursor'), 362 + limit: any(named: 'limit'), 363 + ), 364 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [samplePack])); 365 + 366 + when( 367 + () => mockDatabase.addSearchHistoryEntry( 368 + query: any(named: 'query'), 369 + type: any(named: 'type'), 370 + accountDid: any(named: 'accountDid'), 371 + ), 372 + ).thenAnswer((_) async {}); 373 + 374 + await tester.pumpWidget(buildSubject()); 375 + await tester.pumpAndSettle(); 376 + 377 + await tester.tap(find.text('Starter Packs')); 378 + await tester.pumpAndSettle(); 379 + 380 + final searchField = find.byType(TextField); 381 + await tester.enterText(searchField, 'starter'); 382 + await tester.testTextInput.receiveAction(TextInputAction.search); 383 + await tester.pumpAndSettle(); 384 + 385 + expect(find.text('My Starter Pack'), findsOneWidget); 386 + expect(find.text('by @creator.bsky.social'), findsOneWidget); 387 + expect(find.text('10'), findsOneWidget); 388 + }); 389 + 390 + testWidgets('tapping starter pack result navigates to starter pack detail', (tester) async { 391 + final packUri = AtUri.parse('at://did:plc:creator/app.bsky.graph.starterpack/pack-1'); 392 + final samplePack = StarterPackViewBasic( 393 + uri: packUri, 394 + cid: 'cid-pack-1', 395 + record: const { 396 + r'$type': 'app.bsky.graph.starterpack', 397 + 'name': 'My Starter Pack', 398 + 'list': 'at://did:plc:creator/app.bsky.graph.list/list-1', 399 + 'createdAt': '2026-01-01T00:00:00.000Z', 400 + }, 401 + creator: const ProfileViewBasic(did: 'did:plc:creator', handle: 'creator.bsky.social'), 402 + indexedAt: DateTime.utc(2026, 1, 1), 403 + ); 404 + 405 + when( 406 + () => mockSearchRepository.searchStarterPacks( 407 + query: any(named: 'query'), 408 + cursor: any(named: 'cursor'), 409 + limit: any(named: 'limit'), 410 + ), 411 + ).thenAnswer((_) async => SearchStarterPacksResult(starterPacks: [samplePack])); 412 + 413 + when( 414 + () => mockDatabase.addSearchHistoryEntry( 415 + query: any(named: 'query'), 416 + type: any(named: 'type'), 417 + accountDid: any(named: 'accountDid'), 418 + ), 419 + ).thenAnswer((_) async {}); 420 + 421 + final encodedUri = Uri.encodeComponent(packUri.toString()); 422 + final router = GoRouter( 423 + routes: [ 424 + GoRoute( 425 + path: '/', 426 + builder: (context, state) => BlocProvider<SearchBloc>( 427 + create: (_) => SearchBloc( 428 + searchRepository: mockSearchRepository, 429 + database: mockDatabase, 430 + accountDid: 'did:plc:test', 431 + ), 432 + child: BlocProvider<ConnectivityCubit>.value(value: connectivityCubit, child: const SearchScreen()), 433 + ), 434 + ), 435 + GoRoute( 436 + path: '/starter-pack', 437 + builder: (context, state) => Scaffold(body: Text('starterpack:${state.uri.queryParameters['uri']}')), 438 + ), 439 + ], 440 + ); 441 + 442 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 443 + await tester.pumpAndSettle(); 444 + 445 + await tester.tap(find.text('Starter Packs')); 446 + await tester.pumpAndSettle(); 447 + 448 + final searchField = find.byType(TextField); 449 + await tester.enterText(searchField, 'starter'); 450 + await tester.testTextInput.receiveAction(TextInputAction.search); 451 + await tester.pumpAndSettle(); 452 + 453 + await tester.tap(find.text('My Starter Pack')); 454 + await tester.pumpAndSettle(); 455 + 456 + expect(find.text('starterpack:${packUri.toString()}'), findsOneWidget); 457 + expect(find.text(encodedUri), findsOneWidget); 301 458 }); 302 459 }); 303 460 }