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: search filters & profile post search

+2101 -589
+30
lib/core/router/app_router.dart
··· 54 54 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 55 55 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 56 56 import 'package:lazurite/features/search/cubit/topic_cubit.dart'; 57 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 57 58 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 58 59 import 'package:lazurite/features/search/data/search_repository.dart'; 59 60 import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; ··· 72 73 import 'package:lazurite/features/starter_packs/presentation/actor_starter_packs_screen.dart'; 73 74 import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 74 75 import 'package:lazurite/features/starter_packs/presentation/starter_pack_detail_screen.dart'; 76 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 75 77 76 78 class AppRouter { 77 79 AppRouter({required this.authBloc, this.navigatorObserver}); ··· 507 509 state, 508 510 ProfileScreen(actor: Uri.decodeComponent(state.pathParameters['actor'] ?? ''), showBackButton: true), 509 511 ), 512 + routes: [ 513 + GoRoute( 514 + path: 'search-posts', 515 + pageBuilder: (context, state) { 516 + final actor = Uri.decodeComponent(state.pathParameters['actor'] ?? ''); 517 + return _page( 518 + context, 519 + state, 520 + BlocProvider( 521 + create: (_) => SearchBloc( 522 + searchRepository: context.read<SearchRepository>(), 523 + typeaheadRepository: context.read<TypeaheadRepository>(), 524 + database: context.read<AppDatabase>(), 525 + accountDid: context.read<String>(), 526 + config: SearchBlocConfig.profileScoped(fixedPostAuthor: actor), 527 + ), 528 + child: SearchScreen( 529 + postsOnlyMode: true, 530 + fixedPostAuthor: actor, 531 + showBackButton: true, 532 + title: 'Search @${actor.startsWith('did:') ? actor : actor}', 533 + showJumpToProfileAction: false, 534 + ), 535 + ), 536 + ); 537 + }, 538 + ), 539 + ], 510 540 ), 511 541 ], 512 542 ),
+3 -11
lib/features/feed/presentation/widgets/grid_post_card.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 3 3 import 'package:bluesky/app_bsky_feed_defs.dart'; 4 - import 'package:bluesky/app_bsky_feed_post.dart'; 5 4 import 'package:bluesky/moderation.dart' as bsky_moderation; 6 5 import 'package:cached_network_image/cached_network_image.dart'; 7 6 import 'package:flutter/material.dart'; ··· 19 18 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 20 19 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 21 20 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 21 + import 'package:lazurite/shared/utils/parse_utils.dart'; 22 22 23 23 const double _gridEmbedPreviewMaxHeight = 240; 24 24 ··· 48 48 @override 49 49 Widget build(BuildContext context) { 50 50 final post = feedViewPost.post; 51 - final record = _tryParseRecord(post.record); 51 + final record = tryParseRecord(post.record); 52 52 final primaryImageUrl = _extractPrimaryImageUrl(post.embed); 53 53 final bodyText = record?.text ?? ''; 54 54 final colorScheme = context.colorScheme; ··· 227 227 return null; 228 228 } 229 229 230 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 231 - try { 232 - return FeedPostRecord.fromJson(record); 233 - } catch (_) { 234 - return null; 235 - } 236 - } 237 - 238 230 Widget _buildReplyContext(BuildContext context) { 239 231 final parentPost = feedViewPost.reply?.parent.isPostView == true ? feedViewPost.reply!.parent.postView : null; 240 232 if (parentPost == null) { ··· 254 246 ); 255 247 } 256 248 257 - final parentRecord = _tryParseRecord(parentPost.record); 249 + final parentRecord = tryParseRecord(parentPost.record); 258 250 final parentText = parentRecord?.text.trim() ?? ''; 259 251 return Container( 260 252 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+4 -17
lib/features/feed/presentation/widgets/post_card.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 - import 'package:bluesky/app_bsky_feed_post.dart'; 4 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 14 13 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 15 14 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 16 15 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 16 + import 'package:lazurite/shared/utils/parse_utils.dart'; 17 17 18 18 class PostCard extends StatelessWidget { 19 19 const PostCard({ ··· 39 39 @override 40 40 Widget build(BuildContext context) { 41 41 final post = feedViewPost.post; 42 - final record = _tryParseRecord(post.record); 42 + final record = tryParseRecord(post.record); 43 43 final colorScheme = context.colorScheme; 44 44 final moderationService = maybeModerationService(context); 45 45 final postUi = moderationService?.postUi(post, moderationContext) ?? const bsky_moderation.ModerationUI(); ··· 144 144 ); 145 145 } 146 146 147 - final parentRecord = _tryParseRecord(parentPost.record); 147 + final parentRecord = tryParseRecord(parentPost.record); 148 148 final parentText = parentRecord?.text.trim() ?? ''; 149 149 return Container( 150 150 padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), ··· 162 162 ), 163 163 if (parentText.isNotEmpty) ...[ 164 164 const SizedBox(height: 4), 165 - Text( 166 - parentText, 167 - maxLines: 2, 168 - overflow: TextOverflow.ellipsis, 169 - style: context.textTheme.bodySmall, 170 - ), 165 + Text(parentText, maxLines: 2, overflow: TextOverflow.ellipsis, style: context.textTheme.bodySmall), 171 166 ], 172 167 ], 173 168 ), 174 169 ); 175 - } 176 - 177 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 178 - try { 179 - return FeedPostRecord.fromJson(record); 180 - } catch (_) { 181 - return null; 182 - } 183 170 } 184 171 }
+2 -10
lib/features/feed/presentation/widgets/post_embed_view.dart
··· 4 4 import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 5 5 import 'package:bluesky/app_bsky_embed_video.dart'; 6 6 import 'package:bluesky/app_bsky_feed_defs.dart'; 7 - import 'package:bluesky/app_bsky_feed_post.dart'; 8 7 import 'package:bluesky/moderation.dart' as bsky_moderation; 9 8 import 'package:cached_network_image/cached_network_image.dart'; 10 9 import 'package:flutter/material.dart'; ··· 21 20 import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; 22 21 import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; 23 22 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 23 + import 'package:lazurite/shared/utils/parse_utils.dart'; 24 24 25 25 /// Renders the appropriate embed widget for a post embed. 26 26 /// ··· 204 204 205 205 if (record.isEmbedRecordViewRecord) { 206 206 final quoted = record.embedRecordViewRecord!; 207 - final quotedRecord = _tryParseRecord(quoted.value); 207 + final quotedRecord = tryParseRecord(quoted.value); 208 208 final nestedHeroNamespace = '$heroNamespace/quote:${quoted.uri}'; 209 209 final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds, heroNamespace: '$nestedHeroNamespace/embeds'); 210 210 ··· 393 393 final uri = Uri.tryParse(url); 394 394 final segment = uri?.pathSegments.isNotEmpty == true ? uri!.pathSegments.last : 'image.jpg'; 395 395 return segment.isEmpty ? 'image.jpg' : segment; 396 - } 397 - 398 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 399 - try { 400 - return FeedPostRecord.fromJson(record); 401 - } catch (_) { 402 - return null; 403 - } 404 396 } 405 397 } 406 398
+18 -260
lib/features/profile/presentation/profile_screen.dart
··· 23 23 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 24 24 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 25 25 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 26 - import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 27 26 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 27 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 28 28 import 'package:lazurite/features/lists/cubit/add_to_list_cubit.dart'; 29 29 import 'package:lazurite/features/lists/cubit/my_lists_cubit.dart'; 30 30 import 'package:lazurite/features/lists/data/list_repository.dart'; ··· 38 38 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 39 39 import 'package:lazurite/features/profile/data/profile_repository.dart'; 40 40 import 'package:lazurite/features/profile/presentation/widgets/profile_action_buttons.dart'; 41 + import 'package:lazurite/features/profile/presentation/widgets/profile_liked_posts_pane.dart'; 42 + import 'package:lazurite/features/profile/presentation/widgets/profile_starter_packs_pane.dart'; 41 43 import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; 42 44 import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_sheet.dart'; 43 45 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 44 46 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 45 - import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 46 47 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 47 - import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 48 48 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 49 49 import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 50 50 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; ··· 371 371 return profile?.displayName ?? profile?.handle ?? widget.actor ?? authState.tokens?.handle ?? 'Profile'; 372 372 } 373 373 374 + void _openProfilePostSearch(BuildContext context, ProfileViewDetailed profile) { 375 + final actor = profile.handle.trim().isNotEmpty ? profile.handle.trim() : profile.did; 376 + final location = '/profile/${Uri.encodeComponent(actor)}/search-posts'; 377 + context.push(location); 378 + } 379 + 374 380 Future<void> _refresh() async { 375 381 context.read<ProfileBloc>().add(const ProfileRefreshRequested()); 376 382 context.read<FeedBloc>().add(const FeedRefreshRequested()); ··· 549 555 key: const Key('profile_more_button'), 550 556 icon: const Icon(Icons.more_vert), 551 557 onPressed: () => _showOwnProfileMoreOptions(context, actorScopedProfile), 558 + ), 559 + if (actorScopedProfile != null) 560 + IconButton( 561 + key: const Key('profile_search_posts_button'), 562 + icon: const Icon(Icons.search), 563 + tooltip: 'Search this profile\'s posts', 564 + onPressed: () => _openProfilePostSearch(context, actorScopedProfile), 552 565 ), 553 566 IconButton( 554 567 icon: const Icon(Icons.settings_outlined), ··· 1339 1352 return const SizedBox.shrink(); 1340 1353 } 1341 1354 1342 - return _ProfileLikedPostsPane(actor: actor, profileRepository: profileRepository); 1355 + return ProfileLikedPostsPane(actor: actor, profileRepository: profileRepository); 1343 1356 } 1344 1357 1345 1358 Widget _buildStarterPacksTab(BuildContext context, ProfileViewDetailed? profile) { ··· 1353 1366 return const SizedBox.shrink(); 1354 1367 } 1355 1368 1356 - return _ProfileStarterPacksPane(actor: actor, starterPackRepository: starterPackRepository); 1369 + return ProfileStarterPacksPane(actor: actor, starterPackRepository: starterPackRepository); 1357 1370 } 1358 1371 1359 1372 Future<void> _launchWebsite(String website) async { ··· 1475 1488 padding: const EdgeInsets.symmetric(vertical: 8), 1476 1489 onProfileTap: widget.onProfileTap, 1477 1490 ), 1478 - ); 1479 - } 1480 - } 1481 - 1482 - class _ProfileLikedPostsPane extends StatefulWidget { 1483 - const _ProfileLikedPostsPane({required this.actor, required this.profileRepository}); 1484 - 1485 - final String actor; 1486 - final ProfileRepository profileRepository; 1487 - 1488 - @override 1489 - State<_ProfileLikedPostsPane> createState() => _ProfileLikedPostsPaneState(); 1490 - } 1491 - 1492 - class _ProfileLikedPostsPaneState extends State<_ProfileLikedPostsPane> { 1493 - List<ProfileActorLikeEntry> _entries = const []; 1494 - String? _cursor; 1495 - bool _isLoading = true; 1496 - bool _isLoadingMore = false; 1497 - bool _hasMore = true; 1498 - String? _error; 1499 - 1500 - @override 1501 - void initState() { 1502 - super.initState(); 1503 - _loadInitial(); 1504 - } 1505 - 1506 - @override 1507 - void didUpdateWidget(covariant _ProfileLikedPostsPane oldWidget) { 1508 - super.didUpdateWidget(oldWidget); 1509 - if (oldWidget.actor != widget.actor) { 1510 - _loadInitial(); 1511 - } 1512 - } 1513 - 1514 - Future<void> _loadInitial() async { 1515 - setState(() { 1516 - _isLoading = true; 1517 - _error = null; 1518 - _entries = const []; 1519 - _cursor = null; 1520 - _hasMore = true; 1521 - }); 1522 - 1523 - try { 1524 - final page = await widget.profileRepository.getActorLikes(actor: widget.actor, limit: 50); 1525 - if (!mounted) return; 1526 - setState(() { 1527 - _entries = page.entries; 1528 - _cursor = page.cursor; 1529 - _hasMore = page.cursor != null; 1530 - _isLoading = false; 1531 - }); 1532 - } catch (e) { 1533 - if (!mounted) return; 1534 - setState(() { 1535 - _error = 'Failed to load liked posts: $e'; 1536 - _isLoading = false; 1537 - }); 1538 - } 1539 - } 1540 - 1541 - Future<void> _refresh() async { 1542 - await _loadInitial(); 1543 - } 1544 - 1545 - Future<void> _loadMore() async { 1546 - if (_isLoadingMore || !_hasMore || _cursor == null) { 1547 - return; 1548 - } 1549 - setState(() => _isLoadingMore = true); 1550 - 1551 - try { 1552 - final page = await widget.profileRepository.getActorLikes(actor: widget.actor, cursor: _cursor, limit: 50); 1553 - if (!mounted) return; 1554 - setState(() { 1555 - _entries = [..._entries, ...page.entries]; 1556 - _cursor = page.cursor; 1557 - _hasMore = page.cursor != null; 1558 - _isLoadingMore = false; 1559 - }); 1560 - } catch (_) { 1561 - if (!mounted) return; 1562 - setState(() => _isLoadingMore = false); 1563 - } 1564 - } 1565 - 1566 - @override 1567 - Widget build(BuildContext context) { 1568 - if (_isLoading) { 1569 - return const Center(child: CircularProgressIndicator()); 1570 - } 1571 - 1572 - if (_error != null) { 1573 - return Center( 1574 - child: Column( 1575 - mainAxisSize: MainAxisSize.min, 1576 - children: [ 1577 - Text(_error!), 1578 - const SizedBox(height: 12), 1579 - FilledButton(onPressed: _loadInitial, child: const Text('Retry')), 1580 - ], 1581 - ), 1582 - ); 1583 - } 1584 - 1585 - if (_entries.isEmpty) { 1586 - return const Center(child: Text('No liked posts yet')); 1587 - } 1588 - 1589 - final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 1590 - return RefreshIndicator( 1591 - onRefresh: _refresh, 1592 - child: NotificationListener<ScrollNotification>( 1593 - onNotification: (notification) { 1594 - if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300) { 1595 - _loadMore(); 1596 - } 1597 - return false; 1598 - }, 1599 - child: ListView.builder( 1600 - key: const PageStorageKey<String>('profile-liked-posts-list'), 1601 - itemCount: _entries.length + (_isLoadingMore ? 1 : 0), 1602 - itemBuilder: (context, index) { 1603 - if (index >= _entries.length) { 1604 - return const Padding( 1605 - padding: EdgeInsets.all(16), 1606 - child: Center(child: CircularProgressIndicator()), 1607 - ); 1608 - } 1609 - final entry = _entries[index]; 1610 - if (entry.feedViewPost != null) { 1611 - return PostCardWithActions( 1612 - feedViewPost: entry.feedViewPost!, 1613 - accountDid: accountDid, 1614 - moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 1615 - ); 1616 - } 1617 - 1618 - return _UnavailableLikedPostCard( 1619 - subjectUri: entry.subjectUri ?? '', 1620 - reason: entry.unavailableReason ?? 'Post unavailable', 1621 - ); 1622 - }, 1623 - ), 1624 - ), 1625 - ); 1626 - } 1627 - } 1628 - 1629 - class _UnavailableLikedPostCard extends StatelessWidget { 1630 - const _UnavailableLikedPostCard({required this.subjectUri, required this.reason}); 1631 - 1632 - final String subjectUri; 1633 - final String reason; 1634 - 1635 - @override 1636 - Widget build(BuildContext context) { 1637 - return Card( 1638 - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 1639 - child: ListTile( 1640 - leading: const Icon(Icons.hide_source_outlined), 1641 - title: const Text('Unavailable liked post'), 1642 - subtitle: Text(reason), 1643 - trailing: IconButton( 1644 - icon: const Icon(Icons.open_in_new), 1645 - onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(subjectUri)}'), 1646 - tooltip: 'Open', 1647 - ), 1648 - ), 1649 - ); 1650 - } 1651 - } 1652 - 1653 - /// Pane that loads and displays starter packs for a given [actor] within the profile screen. 1654 - class _ProfileStarterPacksPane extends StatefulWidget { 1655 - const _ProfileStarterPacksPane({required this.actor, required this.starterPackRepository}); 1656 - 1657 - final String actor; 1658 - final StarterPackRepository starterPackRepository; 1659 - 1660 - @override 1661 - State<_ProfileStarterPacksPane> createState() => _ProfileStarterPacksPaneState(); 1662 - } 1663 - 1664 - class _ProfileStarterPacksPaneState extends State<_ProfileStarterPacksPane> { 1665 - late final ActorStarterPacksCubit _cubit; 1666 - 1667 - @override 1668 - void initState() { 1669 - super.initState(); 1670 - _cubit = ActorStarterPacksCubit(starterPackRepository: widget.starterPackRepository)..load(actor: widget.actor); 1671 - } 1672 - 1673 - @override 1674 - void didUpdateWidget(_ProfileStarterPacksPane oldWidget) { 1675 - super.didUpdateWidget(oldWidget); 1676 - if (oldWidget.actor != widget.actor) { 1677 - _cubit.load(actor: widget.actor); 1678 - } 1679 - } 1680 - 1681 - @override 1682 - void dispose() { 1683 - _cubit.close(); 1684 - super.dispose(); 1685 - } 1686 - 1687 - @override 1688 - Widget build(BuildContext context) { 1689 - return BlocBuilder<ActorStarterPacksCubit, ActorStarterPacksState>( 1690 - bloc: _cubit, 1691 - builder: (context, state) { 1692 - if (state.status == ActorStarterPacksStatus.loading) { 1693 - return const Center(child: CircularProgressIndicator()); 1694 - } 1695 - 1696 - if (state.status == ActorStarterPacksStatus.error) { 1697 - return Center( 1698 - child: Column( 1699 - mainAxisSize: MainAxisSize.min, 1700 - children: [ 1701 - Text(state.errorMessage ?? 'Failed to load starter packs'), 1702 - const SizedBox(height: 12), 1703 - FilledButton( 1704 - onPressed: () => _cubit.load(actor: widget.actor), 1705 - child: const Text('Retry'), 1706 - ), 1707 - ], 1708 - ), 1709 - ); 1710 - } 1711 - 1712 - if (state.starterPacks.isEmpty) { 1713 - return const Center(child: Text('No starter packs yet')); 1714 - } 1715 - 1716 - return RefreshIndicator( 1717 - onRefresh: _cubit.refresh, 1718 - child: ListView.builder( 1719 - padding: const EdgeInsets.symmetric(vertical: 8), 1720 - itemCount: state.starterPacks.length, 1721 - itemBuilder: (context, index) => StarterPackCard( 1722 - key: ValueKey(state.starterPacks[index].uri), 1723 - pack: state.starterPacks[index], 1724 - onTap: () { 1725 - final component = Uri.encodeComponent(state.starterPacks[index].uri.toString()); 1726 - final uri = '/starter-pack?uri=$component'; 1727 - context.push(uri); 1728 - }, 1729 - ), 1730 - ), 1731 - ); 1732 - }, 1733 1491 ); 1734 1492 } 1735 1493 }
+178
lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart
··· 1 + import 'package:bluesky/moderation.dart' as bsky_moderation; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 7 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 8 + 9 + class ProfileLikedPostsPane extends StatefulWidget { 10 + const ProfileLikedPostsPane({super.key, required this.actor, required this.profileRepository}); 11 + 12 + final String actor; 13 + final ProfileRepository profileRepository; 14 + 15 + @override 16 + State<ProfileLikedPostsPane> createState() => _ProfileLikedPostsPaneState(); 17 + } 18 + 19 + class _ProfileLikedPostsPaneState extends State<ProfileLikedPostsPane> { 20 + List<ProfileActorLikeEntry> _entries = const []; 21 + String? _cursor; 22 + bool _isLoading = true; 23 + bool _isLoadingMore = false; 24 + bool _hasMore = true; 25 + String? _error; 26 + 27 + @override 28 + void initState() { 29 + super.initState(); 30 + _loadInitial(); 31 + } 32 + 33 + @override 34 + void didUpdateWidget(covariant ProfileLikedPostsPane oldWidget) { 35 + super.didUpdateWidget(oldWidget); 36 + if (oldWidget.actor != widget.actor) { 37 + _loadInitial(); 38 + } 39 + } 40 + 41 + Future<void> _loadInitial() async { 42 + setState(() { 43 + _isLoading = true; 44 + _error = null; 45 + _entries = const []; 46 + _cursor = null; 47 + _hasMore = true; 48 + }); 49 + 50 + try { 51 + final page = await widget.profileRepository.getActorLikes(actor: widget.actor, limit: 50); 52 + if (!mounted) return; 53 + setState(() { 54 + _entries = page.entries; 55 + _cursor = page.cursor; 56 + _hasMore = page.cursor != null; 57 + _isLoading = false; 58 + }); 59 + } catch (e) { 60 + if (!mounted) return; 61 + setState(() { 62 + _error = 'Failed to load liked posts: $e'; 63 + _isLoading = false; 64 + }); 65 + } 66 + } 67 + 68 + Future<void> _refresh() async { 69 + await _loadInitial(); 70 + } 71 + 72 + Future<void> _loadMore() async { 73 + if (_isLoadingMore || !_hasMore || _cursor == null) { 74 + return; 75 + } 76 + setState(() => _isLoadingMore = true); 77 + 78 + try { 79 + final page = await widget.profileRepository.getActorLikes(actor: widget.actor, cursor: _cursor, limit: 50); 80 + if (!mounted) return; 81 + setState(() { 82 + _entries = [..._entries, ...page.entries]; 83 + _cursor = page.cursor; 84 + _hasMore = page.cursor != null; 85 + _isLoadingMore = false; 86 + }); 87 + } catch (_) { 88 + if (!mounted) return; 89 + setState(() => _isLoadingMore = false); 90 + } 91 + } 92 + 93 + @override 94 + Widget build(BuildContext context) { 95 + if (_isLoading) { 96 + return const Center(child: CircularProgressIndicator()); 97 + } 98 + 99 + if (_error != null) { 100 + return Center( 101 + child: Column( 102 + mainAxisSize: MainAxisSize.min, 103 + children: [ 104 + Text(_error!), 105 + const SizedBox(height: 12), 106 + FilledButton(onPressed: _loadInitial, child: const Text('Retry')), 107 + ], 108 + ), 109 + ); 110 + } 111 + 112 + if (_entries.isEmpty) { 113 + return const Center(child: Text('No liked posts yet')); 114 + } 115 + 116 + final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 117 + return RefreshIndicator( 118 + onRefresh: _refresh, 119 + child: NotificationListener<ScrollNotification>( 120 + onNotification: (notification) { 121 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300) { 122 + _loadMore(); 123 + } 124 + return false; 125 + }, 126 + child: ListView.builder( 127 + key: const PageStorageKey<String>('profile-liked-posts-list'), 128 + itemCount: _entries.length + (_isLoadingMore ? 1 : 0), 129 + itemBuilder: (context, index) { 130 + if (index >= _entries.length) { 131 + return const Padding( 132 + padding: EdgeInsets.all(16), 133 + child: Center(child: CircularProgressIndicator()), 134 + ); 135 + } 136 + final entry = _entries[index]; 137 + if (entry.feedViewPost != null) { 138 + return PostCardWithActions( 139 + feedViewPost: entry.feedViewPost!, 140 + accountDid: accountDid, 141 + moderationContext: bsky_moderation.ModerationBehaviorContext.contentList, 142 + ); 143 + } 144 + 145 + return _UnavailableLikedPostCard( 146 + subjectUri: entry.subjectUri ?? '', 147 + reason: entry.unavailableReason ?? 'Post unavailable', 148 + ); 149 + }, 150 + ), 151 + ), 152 + ); 153 + } 154 + } 155 + 156 + class _UnavailableLikedPostCard extends StatelessWidget { 157 + const _UnavailableLikedPostCard({required this.subjectUri, required this.reason}); 158 + 159 + final String subjectUri; 160 + final String reason; 161 + 162 + @override 163 + Widget build(BuildContext context) { 164 + return Card( 165 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 166 + child: ListTile( 167 + leading: const Icon(Icons.hide_source_outlined), 168 + title: const Text('Unavailable liked post'), 169 + subtitle: Text(reason), 170 + trailing: IconButton( 171 + icon: const Icon(Icons.open_in_new), 172 + onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(subjectUri)}'), 173 + tooltip: 'Open', 174 + ), 175 + ), 176 + ); 177 + } 178 + }
+90
lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; 5 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 6 + import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 7 + 8 + /// Pane that loads and displays starter packs for a given [actor] within the profile screen. 9 + class ProfileStarterPacksPane extends StatefulWidget { 10 + const ProfileStarterPacksPane({super.key, required this.actor, required this.starterPackRepository}); 11 + 12 + final String actor; 13 + final StarterPackRepository starterPackRepository; 14 + 15 + @override 16 + State<ProfileStarterPacksPane> createState() => _ProfileStarterPacksPaneState(); 17 + } 18 + 19 + class _ProfileStarterPacksPaneState extends State<ProfileStarterPacksPane> { 20 + late final ActorStarterPacksCubit _cubit; 21 + 22 + @override 23 + void initState() { 24 + super.initState(); 25 + _cubit = ActorStarterPacksCubit(starterPackRepository: widget.starterPackRepository)..load(actor: widget.actor); 26 + } 27 + 28 + @override 29 + void didUpdateWidget(ProfileStarterPacksPane oldWidget) { 30 + super.didUpdateWidget(oldWidget); 31 + if (oldWidget.actor != widget.actor) { 32 + _cubit.load(actor: widget.actor); 33 + } 34 + } 35 + 36 + @override 37 + void dispose() { 38 + _cubit.close(); 39 + super.dispose(); 40 + } 41 + 42 + @override 43 + Widget build(BuildContext context) { 44 + return BlocBuilder<ActorStarterPacksCubit, ActorStarterPacksState>( 45 + bloc: _cubit, 46 + builder: (context, state) { 47 + if (state.status == ActorStarterPacksStatus.loading) { 48 + return const Center(child: CircularProgressIndicator()); 49 + } 50 + 51 + if (state.status == ActorStarterPacksStatus.error) { 52 + return Center( 53 + child: Column( 54 + mainAxisSize: MainAxisSize.min, 55 + children: [ 56 + Text(state.errorMessage ?? 'Failed to load starter packs'), 57 + const SizedBox(height: 12), 58 + FilledButton( 59 + onPressed: () => _cubit.load(actor: widget.actor), 60 + child: const Text('Retry'), 61 + ), 62 + ], 63 + ), 64 + ); 65 + } 66 + 67 + if (state.starterPacks.isEmpty) { 68 + return const Center(child: Text('No starter packs yet')); 69 + } 70 + 71 + return RefreshIndicator( 72 + onRefresh: _cubit.refresh, 73 + child: ListView.builder( 74 + padding: const EdgeInsets.symmetric(vertical: 8), 75 + itemCount: state.starterPacks.length, 76 + itemBuilder: (context, index) => StarterPackCard( 77 + key: ValueKey(state.starterPacks[index].uri), 78 + pack: state.starterPacks[index], 79 + onTap: () { 80 + final component = Uri.encodeComponent(state.starterPacks[index].uri.toString()); 81 + final uri = '/starter-pack?uri=$component'; 82 + context.push(uri); 83 + }, 84 + ), 85 + ), 86 + ); 87 + }, 88 + ); 89 + } 90 + }
+250 -66
lib/features/search/bloc/search_bloc.dart
··· 6 6 import 'package:equatable/equatable.dart'; 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 9 10 import 'package:lazurite/features/search/data/search_repository.dart'; 10 11 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 11 12 12 13 part 'search_state.dart'; 13 14 15 + class SearchBlocConfig extends Equatable { 16 + const SearchBlocConfig.global({this.initialTab = SearchTab.posts, this.initialSort = 'top'}) 17 + : postsOnly = false, 18 + fixedPostAuthor = null, 19 + enableHistory = true; 20 + 21 + const SearchBlocConfig.profileScoped({required this.fixedPostAuthor}) 22 + : postsOnly = true, 23 + enableHistory = false, 24 + initialTab = SearchTab.posts, 25 + initialSort = 'latest'; 26 + 27 + final bool postsOnly; 28 + final String? fixedPostAuthor; 29 + final bool enableHistory; 30 + final SearchTab initialTab; 31 + final String initialSort; 32 + 33 + @override 34 + List<Object?> get props => [postsOnly, fixedPostAuthor, enableHistory, initialTab, initialSort]; 35 + } 36 + 14 37 class SearchBloc extends Bloc<SearchEvent, SearchState> { 15 38 SearchBloc({ 16 39 required SearchRepository searchRepository, 17 40 required TypeaheadRepository typeaheadRepository, 18 41 required AppDatabase database, 19 42 required String accountDid, 43 + SearchBlocConfig config = const SearchBlocConfig.global(), 20 44 }) : _searchRepository = searchRepository, 21 45 _typeaheadRepository = typeaheadRepository, 22 46 _database = database, 23 47 _accountDid = accountDid, 24 - super(const SearchState.initial()) { 48 + _config = config, 49 + super( 50 + const SearchState.initial().copyWith( 51 + currentTab: config.initialTab, 52 + currentSort: config.initialSort == 'latest' ? 'latest' : 'top', 53 + postFilters: config.fixedPostAuthor == null 54 + ? const PostSearchFilters() 55 + : PostSearchFilters(author: config.fixedPostAuthor), 56 + ), 57 + ) { 25 58 on<QuerySubmitted>(_onQuerySubmitted); 26 59 on<SearchTabChanged>(_onSearchTabChanged); 27 60 on<SearchSortChanged>(_onSearchSortChanged); 61 + on<PostFiltersChanged>(_onPostFiltersChanged); 28 62 on<LoadMoreRequested>(_onLoadMoreRequested); 29 63 on<TypeaheadRequested>(_onTypeaheadRequested); 30 64 on<TypeaheadResultsLoaded>(_onTypeaheadResultsLoaded); ··· 33 67 on<HistoryCleared>(_onHistoryCleared); 34 68 on<QueryCleared>(_onQueryCleared); 35 69 36 - add(const HistoryLoaded()); 70 + if (_config.enableHistory) { 71 + add(const HistoryLoaded()); 72 + } 73 + 74 + if (_shouldAutoLoadProfileScopedPosts) { 75 + add(const QuerySubmitted(query: '')); 76 + } 37 77 } 38 78 39 79 final SearchRepository _searchRepository; 40 80 final TypeaheadRepository _typeaheadRepository; 41 81 final AppDatabase _database; 42 82 final String _accountDid; 83 + final SearchBlocConfig _config; 43 84 Timer? _debounceTimer; 44 85 86 + bool get _shouldAutoLoadProfileScopedPosts => 87 + _config.postsOnly && (_config.fixedPostAuthor?.trim().isNotEmpty ?? false); 88 + 89 + SearchState _freshInitialState() { 90 + return const SearchState.initial().copyWith( 91 + currentTab: _config.initialTab, 92 + currentSort: _config.initialSort == 'latest' ? 'latest' : 'top', 93 + postFilters: _config.fixedPostAuthor == null 94 + ? const PostSearchFilters() 95 + : PostSearchFilters(author: _config.fixedPostAuthor), 96 + searchHistory: _config.enableHistory ? state.searchHistory : const <SearchHistoryEntry>[], 97 + ); 98 + } 99 + 45 100 Future<void> _onQuerySubmitted(QuerySubmitted event, Emitter<SearchState> emit) async { 46 101 final query = event.query.trim(); 102 + final currentTab = state.currentTab; 103 + 104 + if (currentTab == SearchTab.posts) { 105 + await _executePostSearch(query: query, emit: emit); 106 + return; 107 + } 108 + 47 109 if (query.isEmpty) { 48 - emit(const SearchState.initial()); 49 - add(const HistoryLoaded()); 110 + emit(_freshInitialState()); 111 + if (_config.enableHistory) { 112 + add(const HistoryLoaded()); 113 + } 50 114 return; 51 115 } 52 116 53 - final currentTab = state.currentTab; 54 117 final currentSort = state.currentSort; 55 118 56 - if (currentTab == SearchTab.posts) { 57 - emit(SearchState.loadingPosts(query: query, sort: currentSort)); 58 - 59 - try { 60 - final result = await _searchRepository.searchPosts(query: query, sort: currentSort, limit: 50); 61 - await _database.addSearchHistoryEntry(query: query, type: 'posts', accountDid: _accountDid); 62 - final history = await _database.getSearchHistory(_accountDid, limit: 50); 63 - 64 - emit( 65 - SearchState.loadedPosts( 66 - query: query, 67 - sort: currentSort, 68 - posts: result.posts, 69 - cursor: result.cursor, 70 - hitsTotal: result.hitsTotal, 71 - ).copyWith(searchHistory: history), 72 - ); 73 - } catch (error) { 74 - emit( 75 - SearchState.error( 76 - query: query, 77 - message: 'Failed to search posts: $error', 78 - tab: currentTab, 79 - sort: currentSort, 80 - ), 81 - ); 82 - } 83 - } else if (currentTab == SearchTab.actors) { 84 - emit(SearchState.loadingActors(query: query)); 119 + if (currentTab == SearchTab.actors) { 120 + emit(SearchState.loadingActors(query: query).copyWith(postFilters: state.postFilters)); 85 121 86 122 try { 87 123 final result = await _searchRepository.searchActors(query: query, limit: 50); 88 - await _database.addSearchHistoryEntry(query: query, type: 'actors', accountDid: _accountDid); 89 - final history = await _database.getSearchHistory(_accountDid, limit: 50); 124 + if (_config.enableHistory) { 125 + await _database.addSearchHistoryEntry(query: query, type: 'actors', accountDid: _accountDid); 126 + } 127 + final history = _config.enableHistory 128 + ? await _database.getSearchHistory(_accountDid, limit: 50) 129 + : const <SearchHistoryEntry>[]; 90 130 91 131 emit( 92 132 SearchState.loadedActors( 93 133 query: query, 94 134 actors: result.actors, 95 135 cursor: result.cursor, 96 - ).copyWith(searchHistory: history), 136 + ).copyWith(searchHistory: history, postFilters: state.postFilters), 97 137 ); 98 138 } catch (error) { 99 139 emit( ··· 102 142 message: 'Failed to search actors: $error', 103 143 tab: currentTab, 104 144 sort: currentSort, 145 + postFilters: state.postFilters, 105 146 ), 106 147 ); 107 148 } 108 - } else if (currentTab == SearchTab.feeds) { 109 - emit(SearchState.loadingFeeds(query: query)); 149 + return; 150 + } 151 + 152 + if (currentTab == SearchTab.feeds) { 153 + emit(SearchState.loadingFeeds(query: query).copyWith(postFilters: state.postFilters)); 110 154 111 155 try { 112 156 final result = await _searchRepository.searchFeedGenerators(query: query, limit: 25); 113 - await _database.addSearchHistoryEntry(query: query, type: 'feeds', accountDid: _accountDid); 114 - final history = await _database.getSearchHistory(_accountDid, limit: 50); 157 + if (_config.enableHistory) { 158 + await _database.addSearchHistoryEntry(query: query, type: 'feeds', accountDid: _accountDid); 159 + } 160 + final history = _config.enableHistory 161 + ? await _database.getSearchHistory(_accountDid, limit: 50) 162 + : const <SearchHistoryEntry>[]; 115 163 116 164 emit( 117 165 SearchState.loadedFeeds( 118 166 query: query, 119 167 feeds: result.feeds, 120 168 cursor: result.cursor, 121 - ).copyWith(searchHistory: history), 169 + ).copyWith(searchHistory: history, postFilters: state.postFilters), 122 170 ); 123 171 } catch (error) { 124 172 emit( ··· 127 175 message: 'Failed to search feeds: $error', 128 176 tab: currentTab, 129 177 sort: currentSort, 178 + postFilters: state.postFilters, 130 179 ), 131 180 ); 132 181 } 133 - } else { 182 + return; 183 + } 184 + 185 + emit( 186 + SearchState.loadedStarterPacks(query: query, starterPacks: const [], starterPacksCursor: null).copyWith( 187 + searchHistory: state.searchHistory, 188 + typeaheadActors: state.typeaheadActors, 189 + postFilters: state.postFilters, 190 + ), 191 + ); 192 + } 193 + 194 + Future<void> _executePostSearch({ 195 + required String query, 196 + required Emitter<SearchState> emit, 197 + bool loadMore = false, 198 + }) async { 199 + final currentSort = state.currentSort; 200 + final cursor = loadMore ? state.cursor : null; 201 + 202 + try { 203 + final request = PostSearchRequest( 204 + query: query, 205 + sort: currentSort, 206 + filters: state.postFilters, 207 + cursor: cursor, 208 + limit: 50, 209 + ).normalized(fixedAuthor: _config.fixedPostAuthor); 210 + 211 + if (loadMore) { 212 + if (state.cursor == null || state.isLoadingMore) { 213 + return; 214 + } 215 + emit(state.copyWith(isLoadingMore: true, errorMessage: null)); 216 + } else { 217 + emit( 218 + SearchState.loadingPosts(query: query, sort: currentSort, postFilters: request.filters).copyWith( 219 + currentTab: SearchTab.posts, 220 + searchHistory: state.searchHistory, 221 + typeaheadActors: state.typeaheadActors, 222 + ), 223 + ); 224 + } 225 + 226 + final result = await _searchRepository.searchPosts( 227 + query: request.query, 228 + sort: request.sort, 229 + filters: request.filters, 230 + cursor: request.cursor, 231 + limit: request.limit, 232 + ); 233 + 234 + final posts = loadMore ? [...state.posts, ...result.posts] : result.posts; 235 + 236 + List<SearchHistoryEntry> history = state.searchHistory; 237 + if (!loadMore && _config.enableHistory && request.query.isNotEmpty) { 238 + await _database.addSearchHistoryEntry(query: request.query, type: 'posts', accountDid: _accountDid); 239 + history = await _database.getSearchHistory(_accountDid, limit: 50); 240 + } 241 + 134 242 emit( 135 - SearchState.loadedStarterPacks( 243 + SearchState.loadedPosts( 244 + query: request.query, 245 + sort: request.sort, 246 + postFilters: request.filters, 247 + posts: posts, 248 + cursor: result.cursor, 249 + hitsTotal: result.hitsTotal, 250 + ).copyWith(searchHistory: history, typeaheadActors: state.typeaheadActors, isLoadingMore: false), 251 + ); 252 + } on PostSearchValidationException catch (error) { 253 + if (loadMore) { 254 + emit(state.copyWith(isLoadingMore: false)); 255 + return; 256 + } 257 + emit( 258 + SearchState.error( 259 + query: query, 260 + message: error.message, 261 + tab: SearchTab.posts, 262 + sort: currentSort, 263 + postFilters: state.postFilters, 264 + ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 265 + ); 266 + } catch (error) { 267 + if (loadMore) { 268 + emit(state.copyWith(isLoadingMore: false)); 269 + return; 270 + } 271 + emit( 272 + SearchState.error( 136 273 query: query, 137 - starterPacks: const [], 138 - starterPacksCursor: null, 274 + message: 'Failed to search posts: $error', 275 + tab: SearchTab.posts, 276 + sort: currentSort, 277 + postFilters: state.postFilters, 139 278 ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 140 279 ); 141 280 } 142 281 } 143 282 144 283 Future<void> _onSearchTabChanged(SearchTabChanged event, Emitter<SearchState> emit) async { 145 - if (state.currentTab == event.tab) return; 284 + if (_config.postsOnly || state.currentTab == event.tab) { 285 + return; 286 + } 146 287 147 288 emit(state.copyWith(currentTab: event.tab)); 148 289 149 - if (state.query.isNotEmpty) { 290 + if (state.query.isNotEmpty || (event.tab == SearchTab.posts && !state.postFilters.isEmpty)) { 150 291 add(QuerySubmitted(query: state.query)); 151 292 } 152 293 } ··· 156 297 157 298 emit(state.copyWith(currentSort: event.sort)); 158 299 159 - if (state.query.isNotEmpty && state.currentTab == SearchTab.posts) { 300 + if (state.currentTab == SearchTab.posts && (state.query.isNotEmpty || !state.postFilters.isEmpty)) { 160 301 add(QuerySubmitted(query: state.query)); 161 302 } 162 303 } 163 304 305 + Future<void> _onPostFiltersChanged(PostFiltersChanged event, Emitter<SearchState> emit) async { 306 + try { 307 + final resolved = event.filters.normalized(fixedAuthor: _config.fixedPostAuthor); 308 + emit(state.copyWith(postFilters: resolved)); 309 + 310 + if (state.currentTab == SearchTab.posts && (state.query.isNotEmpty || !resolved.isEmpty)) { 311 + add(QuerySubmitted(query: state.query)); 312 + } 313 + } on PostSearchValidationException catch (error) { 314 + emit( 315 + SearchState.error( 316 + query: state.query, 317 + message: error.message, 318 + tab: SearchTab.posts, 319 + sort: state.currentSort, 320 + postFilters: state.postFilters, 321 + ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 322 + ); 323 + } 324 + } 325 + 164 326 Future<void> _onLoadMoreRequested(LoadMoreRequested event, Emitter<SearchState> emit) async { 165 327 if (state.isLoadingMore) return; 166 328 ··· 184 346 query: state.query, 185 347 feeds: [...state.feeds, ...result.feeds], 186 348 cursor: result.cursor, 187 - ).copyWith(searchHistory: state.searchHistory, typeaheadActors: state.typeaheadActors), 349 + ).copyWith( 350 + searchHistory: state.searchHistory, 351 + typeaheadActors: state.typeaheadActors, 352 + postFilters: state.postFilters, 353 + ), 188 354 ); 189 355 } catch (error) { 190 356 emit(state.copyWith(isLoadingMore: false)); ··· 192 358 return; 193 359 } 194 360 361 + if (state.currentTab == SearchTab.posts) { 362 + await _executePostSearch(query: state.query, emit: emit, loadMore: true); 363 + return; 364 + } 365 + 195 366 if (state.cursor == null) return; 196 367 197 368 emit(state.copyWith(isLoadingMore: true)); 198 369 199 370 try { 200 - if (state.currentTab == SearchTab.posts) { 201 - final result = await _searchRepository.searchPosts( 202 - query: state.query, 203 - sort: state.currentSort, 204 - cursor: state.cursor, 205 - limit: 50, 206 - ); 207 - 208 - emit(state.copyWith(posts: [...state.posts, ...result.posts], cursor: result.cursor, isLoadingMore: false)); 209 - } else { 210 - final result = await _searchRepository.searchActors(query: state.query, cursor: state.cursor, limit: 50); 211 - 212 - emit(state.copyWith(actors: [...state.actors, ...result.actors], cursor: result.cursor, isLoadingMore: false)); 213 - } 371 + final result = await _searchRepository.searchActors(query: state.query, cursor: state.cursor, limit: 50); 372 + emit(state.copyWith(actors: [...state.actors, ...result.actors], cursor: result.cursor, isLoadingMore: false)); 214 373 } catch (error) { 215 374 emit(state.copyWith(isLoadingMore: false)); 216 375 } ··· 248 407 } 249 408 250 409 Future<void> _onHistoryLoaded(HistoryLoaded event, Emitter<SearchState> emit) async { 410 + if (!_config.enableHistory) { 411 + emit(state.copyWith(searchHistory: [])); 412 + return; 413 + } 251 414 final entries = await _database.getSearchHistory(_accountDid, limit: 50); 252 415 emit(state.copyWith(searchHistory: entries)); 253 416 } 254 417 255 418 Future<void> _onHistoryEntryDeleted(HistoryEntryDeleted event, Emitter<SearchState> emit) async { 419 + if (!_config.enableHistory) { 420 + return; 421 + } 256 422 await _database.deleteSearchHistoryEntry(event.id); 257 423 final entries = await _database.getSearchHistory(_accountDid, limit: 50); 258 424 emit(state.copyWith(searchHistory: entries)); 259 425 } 260 426 261 427 Future<void> _onHistoryCleared(HistoryCleared event, Emitter<SearchState> emit) async { 428 + if (!_config.enableHistory) { 429 + return; 430 + } 262 431 await _database.clearSearchHistory(_accountDid); 263 432 emit(state.copyWith(searchHistory: [])); 264 433 } 265 434 266 435 void _onQueryCleared(QueryCleared event, Emitter<SearchState> emit) { 267 - emit(const SearchState.initial()); 268 - add(const HistoryLoaded()); 436 + emit(_freshInitialState()); 437 + if (_shouldAutoLoadProfileScopedPosts) { 438 + add(const QuerySubmitted(query: '')); 439 + return; 440 + } 441 + if (_config.enableHistory) { 442 + add(const HistoryLoaded()); 443 + } 269 444 } 270 445 271 446 @override ··· 307 482 308 483 @override 309 484 List<Object?> get props => [sort]; 485 + } 486 + 487 + class PostFiltersChanged extends SearchEvent { 488 + const PostFiltersChanged({required this.filters}); 489 + 490 + final PostSearchFilters filters; 491 + 492 + @override 493 + List<Object?> get props => [filters]; 310 494 } 311 495 312 496 class LoadMoreRequested extends SearchEvent {
+33 -4
lib/features/search/bloc/search_state.dart
··· 9 9 SearchTab.feeds => 'Feeds', 10 10 SearchTab.starterPacks => 'Starter Packs', 11 11 }; 12 + 13 + String get placeholder => switch (this) { 14 + SearchTab.posts => 'Search posts', 15 + SearchTab.actors => 'Search people', 16 + SearchTab.feeds => 'Search feeds', 17 + SearchTab.starterPacks => 'Starter pack search unavailable', 18 + }; 12 19 } 13 20 14 21 enum SearchSort { ··· 36 43 this.query = '', 37 44 this.currentTab = SearchTab.posts, 38 45 this.currentSort = 'top', 46 + this.postFilters = const PostSearchFilters(), 39 47 this.posts = const [], 40 48 this.actors = const [], 41 49 this.feeds = const [], ··· 51 59 52 60 const SearchState.initial() : this._(status: SearchStatus.initial); 53 61 54 - const SearchState.loadingPosts({required String query, required String sort}) 55 - : this._(status: SearchStatus.loading, query: query, currentSort: sort); 62 + const SearchState.loadingPosts({ 63 + required String query, 64 + required String sort, 65 + PostSearchFilters postFilters = const PostSearchFilters(), 66 + }) : this._(status: SearchStatus.loading, query: query, currentSort: sort, postFilters: postFilters); 56 67 57 68 const SearchState.loadingActors({required String query}) 58 69 : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.actors); ··· 66 77 const SearchState.loadedPosts({ 67 78 required String query, 68 79 required String sort, 80 + PostSearchFilters postFilters = const PostSearchFilters(), 69 81 required List<PostView> posts, 70 82 String? cursor, 71 83 int? hitsTotal, ··· 73 85 status: SearchStatus.loaded, 74 86 query: query, 75 87 currentSort: sort, 88 + postFilters: postFilters, 76 89 posts: posts, 77 90 cursor: cursor, 78 91 hitsTotal: hitsTotal, ··· 96 109 starterPacksCursor: starterPacksCursor, 97 110 ); 98 111 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); 112 + const SearchState.error({ 113 + required String query, 114 + required String message, 115 + required SearchTab tab, 116 + String sort = 'top', 117 + PostSearchFilters postFilters = const PostSearchFilters(), 118 + }) : this._( 119 + status: SearchStatus.error, 120 + query: query, 121 + currentTab: tab, 122 + currentSort: sort, 123 + postFilters: postFilters, 124 + errorMessage: message, 125 + ); 101 126 102 127 static const Object _unset = Object(); 103 128 ··· 105 130 final String query; 106 131 final SearchTab currentTab; 107 132 final String currentSort; 133 + final PostSearchFilters postFilters; 108 134 final List<PostView> posts; 109 135 final List<ProfileView> actors; 110 136 final List<GeneratorView> feeds; ··· 127 153 String? query, 128 154 SearchTab? currentTab, 129 155 String? currentSort, 156 + PostSearchFilters? postFilters, 130 157 List<PostView>? posts, 131 158 List<ProfileView>? actors, 132 159 List<GeneratorView>? feeds, ··· 143 170 query: query ?? this.query, 144 171 currentTab: currentTab ?? this.currentTab, 145 172 currentSort: currentSort ?? this.currentSort, 173 + postFilters: postFilters ?? this.postFilters, 146 174 posts: posts ?? this.posts, 147 175 actors: actors ?? this.actors, 148 176 feeds: feeds ?? this.feeds, ··· 162 190 query, 163 191 currentTab, 164 192 currentSort, 193 + postFilters, 165 194 posts, 166 195 actors, 167 196 feeds,
+2 -9
lib/features/search/data/hashtag_utils.dart
··· 2 2 import 'package:bluesky/app_bsky_feed_post.dart'; 3 3 import 'package:bluesky/app_bsky_richtext_facet.dart'; 4 4 import 'package:bluesky_text/bluesky_text.dart'; 5 + import 'package:lazurite/shared/utils/parse_utils.dart'; 5 6 6 7 String normalizeHashtag(String input) { 7 8 final trimmed = input.trim(); ··· 24 25 final canonical = <String, String>{}; 25 26 26 27 for (final post in posts) { 27 - final record = _tryParseRecord(post.record); 28 + final record = tryParseRecord(post.record); 28 29 if (record == null) { 29 30 continue; 30 31 } ··· 53 54 }); 54 55 55 56 return entries.take(limit).map((entry) => canonical[entry.key] ?? entry.key).toList(growable: false); 56 - } 57 - 58 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> rawRecord) { 59 - try { 60 - return FeedPostRecord.fromJson(rawRecord); 61 - } catch (_) { 62 - return null; 63 - } 64 57 } 65 58 66 59 Iterable<String> _extractTags(FeedPostRecord record) sync* {
+165
lib/features/search/data/post_search_filters.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + 3 + class PostSearchValidationException implements Exception { 4 + const PostSearchValidationException(this.message); 5 + 6 + final String message; 7 + 8 + @override 9 + String toString() => message; 10 + } 11 + 12 + class PostSearchFilters extends Equatable { 13 + const PostSearchFilters({ 14 + this.since, 15 + this.until, 16 + this.mentions, 17 + this.author, 18 + this.lang, 19 + this.domain, 20 + this.url, 21 + this.tags = const <String>[], 22 + }); 23 + 24 + final DateTime? since; 25 + final DateTime? until; 26 + final String? mentions; 27 + final String? author; 28 + final String? lang; 29 + final String? domain; 30 + final String? url; 31 + final List<String> tags; 32 + 33 + static const Object _unset = Object(); 34 + 35 + bool get isEmpty { 36 + return since == null && 37 + until == null && 38 + (mentions == null || mentions!.isEmpty) && 39 + (author == null || author!.isEmpty) && 40 + (lang == null || lang!.isEmpty) && 41 + (domain == null || domain!.isEmpty) && 42 + (url == null || url!.isEmpty) && 43 + tags.isEmpty; 44 + } 45 + 46 + PostSearchFilters copyWith({ 47 + Object? since = _unset, 48 + Object? until = _unset, 49 + Object? mentions = _unset, 50 + Object? author = _unset, 51 + Object? lang = _unset, 52 + Object? domain = _unset, 53 + Object? url = _unset, 54 + List<String>? tags, 55 + }) { 56 + return PostSearchFilters( 57 + since: identical(since, _unset) ? this.since : since as DateTime?, 58 + until: identical(until, _unset) ? this.until : until as DateTime?, 59 + mentions: identical(mentions, _unset) ? this.mentions : mentions as String?, 60 + author: identical(author, _unset) ? this.author : author as String?, 61 + lang: identical(lang, _unset) ? this.lang : lang as String?, 62 + domain: identical(domain, _unset) ? this.domain : domain as String?, 63 + url: identical(url, _unset) ? this.url : url as String?, 64 + tags: tags ?? this.tags, 65 + ); 66 + } 67 + 68 + PostSearchFilters clearAll() => const PostSearchFilters(); 69 + 70 + PostSearchFilters normalized({String? fixedAuthor}) { 71 + final normalizedSince = since?.toUtc(); 72 + final normalizedUntil = until?.toUtc(); 73 + if (normalizedSince != null && normalizedUntil != null && normalizedSince.isAfter(normalizedUntil)) { 74 + throw const PostSearchValidationException('"Since" must be before or equal to "Until".'); 75 + } 76 + 77 + final resolvedAuthor = _trimOrNull(fixedAuthor) ?? _trimOrNull(author); 78 + 79 + return PostSearchFilters( 80 + since: normalizedSince, 81 + until: normalizedUntil, 82 + mentions: _trimOrNull(mentions), 83 + author: resolvedAuthor, 84 + lang: _trimOrNull(lang), 85 + domain: _trimOrNull(domain), 86 + url: _trimOrNull(url), 87 + tags: _normalizeTags(tags), 88 + ); 89 + } 90 + 91 + String? get sinceIso => since?.toUtc().toIso8601String(); 92 + 93 + String? get untilIso => until?.toUtc().toIso8601String(); 94 + 95 + static List<String> _normalizeTags(List<String> raw) { 96 + final normalized = <String>[]; 97 + final seen = <String>{}; 98 + for (final value in raw) { 99 + final trimmed = value.trim(); 100 + if (trimmed.isEmpty) { 101 + continue; 102 + } 103 + 104 + final tag = trimmed.startsWith('#') ? trimmed.substring(1).trim() : trimmed; 105 + if (tag.isEmpty) { 106 + continue; 107 + } 108 + 109 + final key = tag.toLowerCase(); 110 + if (seen.add(key)) { 111 + normalized.add(tag); 112 + } 113 + } 114 + return normalized; 115 + } 116 + 117 + static String? _trimOrNull(String? value) { 118 + final trimmed = value?.trim(); 119 + if (trimmed == null || trimmed.isEmpty) { 120 + return null; 121 + } 122 + return trimmed; 123 + } 124 + 125 + @override 126 + List<Object?> get props => [since, until, mentions, author, lang, domain, url, tags]; 127 + } 128 + 129 + class PostSearchRequest extends Equatable { 130 + const PostSearchRequest({ 131 + required this.query, 132 + this.sort = 'top', 133 + this.filters = const PostSearchFilters(), 134 + this.cursor, 135 + this.limit = 50, 136 + }); 137 + 138 + final String query; 139 + final String sort; 140 + final PostSearchFilters filters; 141 + final String? cursor; 142 + final int limit; 143 + 144 + PostSearchRequest normalized({String? fixedAuthor}) { 145 + final normalizedQuery = query.trim(); 146 + final normalizedFilters = filters.normalized(fixedAuthor: fixedAuthor); 147 + if (normalizedQuery.isEmpty && normalizedFilters.isEmpty) { 148 + throw const PostSearchValidationException('Enter a query or at least one filter.'); 149 + } 150 + 151 + final normalizedSort = sort == 'latest' ? 'latest' : 'top'; 152 + final normalizedLimit = limit.clamp(1, 100); 153 + 154 + return PostSearchRequest( 155 + query: normalizedQuery, 156 + sort: normalizedSort, 157 + filters: normalizedFilters, 158 + cursor: cursor, 159 + limit: normalizedLimit, 160 + ); 161 + } 162 + 163 + @override 164 + List<Object?> get props => [query, sort, filters, cursor, limit]; 165 + }
+22 -4
lib/features/search/data/search_repository.dart
··· 10 10 import 'package:lazurite/core/network/app_view_request_context.dart'; 11 11 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 12 12 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 13 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 13 14 14 15 class SearchRepository { 15 16 SearchRepository({ ··· 47 48 Future<SearchPostsResult> searchPosts({ 48 49 required String query, 49 50 String sort = 'top', 51 + PostSearchFilters filters = const PostSearchFilters(), 50 52 String? cursor, 51 53 int limit = 50, 52 54 }) async { 53 - final sortValue = sort == 'latest' 55 + final normalized = PostSearchRequest( 56 + query: query, 57 + sort: sort, 58 + filters: filters, 59 + cursor: cursor, 60 + limit: limit, 61 + ).normalized(); 62 + 63 + final sortValue = normalized.sort == 'latest' 54 64 ? const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest) 55 65 : const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.top); 56 66 57 67 final response = await _bluesky.feed.searchPosts( 58 - q: query, 68 + q: normalized.query.isEmpty ? '*' : normalized.query, 59 69 sort: sortValue, 60 - cursor: cursor, 61 - limit: limit, 70 + since: normalized.filters.sinceIso, 71 + until: normalized.filters.untilIso, 72 + mentions: normalized.filters.mentions, 73 + author: normalized.filters.author, 74 + lang: normalized.filters.lang, 75 + domain: normalized.filters.domain, 76 + url: normalized.filters.url, 77 + tag: normalized.filters.tags.isEmpty ? null : normalized.filters.tags, 78 + cursor: normalized.cursor, 79 + limit: normalized.limit, 62 80 $headers: _appViewContext.appBskyHeadersForEndpoint( 63 81 'app.bsky.feed.searchPosts', 64 82 await _moderationService?.headersForRequest(),
+5 -17
lib/features/search/presentation/hashtag_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 - import 'package:bluesky/app_bsky_feed_post.dart'; 4 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_animate/flutter_animate.dart'; ··· 8 7 import 'package:go_router/go_router.dart'; 9 8 import 'package:lazurite/core/theme/animation_tokens.dart'; 10 9 import 'package:lazurite/core/theme/animation_utils.dart'; 10 + import 'package:lazurite/core/theme/theme_extensions.dart'; 11 11 import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 12 12 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 13 13 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; ··· 15 15 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 16 16 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 17 17 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 18 + import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 18 19 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 19 20 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 20 - import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 21 21 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 22 22 import 'package:lazurite/shared/utils/format_utils.dart'; 23 - import 'package:lazurite/core/theme/theme_extensions.dart'; 23 + import 'package:lazurite/shared/utils/parse_utils.dart'; 24 24 25 25 class HashtagScreen extends StatefulWidget { 26 26 const HashtagScreen({super.key, required this.tag}); ··· 284 284 285 285 @override 286 286 Widget build(BuildContext context) { 287 - final record = _tryParseRecord(post.record); 287 + final record = tryParseRecord(post.record); 288 288 final createdAt = record?.createdAt ?? post.indexedAt; 289 289 final moderationService = maybeModerationService(context); 290 290 final postUi = ··· 324 324 const bsky_moderation.ModerationUI(); 325 325 326 326 return InkWell( 327 - onTap: () => _navigateToProfile(context, author.did), 327 + onTap: () => navigateToProfile(context, author.did), 328 328 child: Row( 329 329 crossAxisAlignment: CrossAxisAlignment.start, 330 330 children: [ ··· 390 390 ), 391 391 ), 392 392 ); 393 - } 394 - 395 - void _navigateToProfile(BuildContext context, String did) { 396 - navigateToProfile(context, did); 397 - } 398 - 399 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 400 - try { 401 - return FeedPostRecord.fromJson(record); 402 - } catch (_) { 403 - return null; 404 - } 405 393 } 406 394 }
+404 -179
lib/features/search/presentation/search_screen.dart
··· 1 1 import 'package:bluesky/app_bsky_actor_defs.dart'; 2 2 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 - import 'package:bluesky/app_bsky_feed_post.dart'; 4 3 import 'package:bluesky/moderation.dart' as bsky_moderation; 5 4 import 'package:flutter/material.dart'; 6 5 import 'package:flutter_animate/flutter_animate.dart'; ··· 9 8 import 'package:lazurite/core/router/app_shell.dart'; 10 9 import 'package:lazurite/core/theme/animation_tokens.dart'; 11 10 import 'package:lazurite/core/theme/animation_utils.dart'; 11 + import 'package:lazurite/core/theme/theme_extensions.dart'; 12 12 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 13 13 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 14 14 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; ··· 17 17 import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; 18 18 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 19 19 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 20 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 21 + import 'package:lazurite/features/search/presentation/widgets/search_result_states.dart'; 20 22 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 21 23 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 22 24 import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; 23 25 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 24 26 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 25 - import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 26 27 import 'package:lazurite/shared/presentation/widgets/app_screen_entrance.dart'; 28 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 27 29 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 28 30 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 29 31 import 'package:lazurite/shared/utils/format_utils.dart'; 30 - import 'package:lazurite/core/theme/theme_extensions.dart'; 32 + import 'package:lazurite/shared/utils/parse_utils.dart'; 31 33 import 'package:url_launcher/url_launcher.dart'; 32 34 33 35 class SearchScreen extends StatefulWidget { 34 - const SearchScreen({super.key}); 36 + const SearchScreen({ 37 + super.key, 38 + this.postsOnlyMode = false, 39 + this.fixedPostAuthor, 40 + this.showBackButton = false, 41 + this.title, 42 + this.showJumpToProfileAction = true, 43 + }); 44 + 45 + final bool postsOnlyMode; 46 + final String? fixedPostAuthor; 47 + final bool showBackButton; 48 + final String? title; 49 + final bool showJumpToProfileAction; 35 50 36 51 @override 37 52 State<SearchScreen> createState() => _SearchScreenState(); ··· 72 87 } 73 88 74 89 void _onSubmit(String query) { 75 - if (query.trim().isEmpty) return; 76 - if (context.read<SearchBloc>().state.currentTab == SearchTab.starterPacks) { 90 + final state = context.read<SearchBloc>().state; 91 + if (query.trim().isEmpty && state.currentTab != SearchTab.posts) { 92 + return; 93 + } 94 + if (state.currentTab == SearchTab.starterPacks) { 77 95 _focusNode.unfocus(); 78 96 return; 79 97 } ··· 88 106 } 89 107 90 108 void _onTabChanged(SearchTab tab) { 109 + if (widget.postsOnlyMode) { 110 + return; 111 + } 91 112 context.read<SearchBloc>().add(SearchTabChanged(tab: tab)); 92 113 if (tab == SearchTab.starterPacks) { 93 114 _focusNode.unfocus(); ··· 200 221 201 222 @override 202 223 Widget build(BuildContext context) { 224 + final shouldShowFab = widget.showJumpToProfileAction && !widget.postsOnlyMode; 203 225 return AppScreenEntrance( 204 226 child: Scaffold( 205 - floatingActionButton: 206 - FloatingActionButton.extended( 207 - onPressed: _openJumpToProfileDialog, 208 - icon: const Icon(Icons.person_search), 209 - label: const Text('Jump to profile'), 210 - ).animateIfAllowed( 211 - context, 212 - effects: const [ 213 - FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 214 - ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 215 - ], 216 - ), 227 + appBar: widget.postsOnlyMode 228 + ? AppBar( 229 + title: Text(widget.title ?? 'Search Posts'), 230 + leading: widget.showBackButton 231 + ? IconButton( 232 + icon: const Icon(Icons.arrow_back), 233 + onPressed: () => context.canPop() ? context.pop() : context.go('/search'), 234 + ) 235 + : null, 236 + ) 237 + : null, 238 + floatingActionButton: shouldShowFab 239 + ? FloatingActionButton.extended( 240 + onPressed: _openJumpToProfileDialog, 241 + icon: const Icon(Icons.person_search), 242 + label: const Text('Jump to profile'), 243 + ).animateIfAllowed( 244 + context, 245 + effects: const [ 246 + FadeEffect(duration: Anim.feedItem, curve: Anim.enter), 247 + ScaleEffect(begin: Offset(0, 0), end: Offset(1, 1), duration: Anim.feedItem, curve: Anim.emphasis), 248 + ], 249 + ) 250 + : null, 217 251 body: SafeArea( 218 252 child: BlocBuilder<SearchBloc, SearchState>( 219 253 builder: (context, state) { 220 254 return Column( 221 255 children: [ 222 256 _buildSearchBar(context, state), 223 - _buildTabs(context, state), 224 - if (state.currentTab == SearchTab.posts && state.hasResults) _buildSortToggle(context, state), 257 + if (!widget.postsOnlyMode) _buildTabs(context, state), 258 + if (state.currentTab == SearchTab.posts || widget.postsOnlyMode) _buildSortToggle(context, state), 259 + if (state.currentTab == SearchTab.posts || widget.postsOnlyMode) 260 + _buildPostFilterChips(context, state), 225 261 Expanded(child: _buildBody(context, state)), 226 262 ], 227 263 ); ··· 246 282 ), 247 283 child: Row( 248 284 children: [ 249 - const AppShellMenuButton(), 285 + if (!widget.postsOnlyMode) const AppShellMenuButton() else const SizedBox(width: 0), 250 286 const SizedBox(width: 8), 251 287 Expanded( 252 288 child: TextField( 253 289 controller: _searchController, 254 290 focusNode: _focusNode, 255 291 enabled: !isSearchDisabled, 292 + autocorrect: false, 293 + enableSuggestions: false, 294 + smartDashesType: SmartDashesType.disabled, 295 + smartQuotesType: SmartQuotesType.disabled, 256 296 onSubmitted: _onSubmit, 257 297 textInputAction: TextInputAction.search, 258 298 decoration: InputDecoration( ··· 365 405 decoration: BoxDecoration( 366 406 border: Border(top: BorderSide(color: theme.dividerColor)), 367 407 ), 368 - child: Row( 408 + child: Wrap( 409 + spacing: 8, 410 + runSpacing: 8, 411 + crossAxisAlignment: WrapCrossAlignment.center, 369 412 children: [ 370 413 Text('Sort by', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), 371 - const SizedBox(width: 8), 372 414 Container( 373 415 decoration: BoxDecoration( 374 416 color: theme.colorScheme.surfaceContainerHighest, ··· 381 423 _buildSortOption(context, SearchSort.latest, state), 382 424 ], 383 425 ), 426 + ), 427 + OutlinedButton.icon( 428 + onPressed: () => _openPostFiltersSheet(state.postFilters), 429 + icon: const Icon(Icons.tune, size: 16), 430 + label: const Text('Filters'), 384 431 ), 385 432 ], 386 433 ), ··· 408 455 ); 409 456 } 410 457 458 + Widget _buildPostFilterChips(BuildContext context, SearchState state) { 459 + final filters = state.postFilters; 460 + final chips = <Widget>[]; 461 + 462 + void addChip(String label, {required VoidCallback onDeleted}) { 463 + chips.add( 464 + InputChip( 465 + label: Text(label), 466 + onDeleted: onDeleted, 467 + deleteIcon: const Icon(Icons.close, size: 16), 468 + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, 469 + ), 470 + ); 471 + } 472 + 473 + if (filters.mentions?.isNotEmpty == true) { 474 + addChip('Mentions: ${filters.mentions}', onDeleted: () => _updatePostFilters(filters.copyWith(mentions: null))); 475 + } 476 + if (widget.fixedPostAuthor == null && filters.author?.isNotEmpty == true) { 477 + addChip('Author: ${filters.author}', onDeleted: () => _updatePostFilters(filters.copyWith(author: null))); 478 + } 479 + if (filters.lang?.isNotEmpty == true) { 480 + addChip('Lang: ${filters.lang}', onDeleted: () => _updatePostFilters(filters.copyWith(lang: null))); 481 + } 482 + if (filters.domain?.isNotEmpty == true) { 483 + addChip('Domain: ${filters.domain}', onDeleted: () => _updatePostFilters(filters.copyWith(domain: null))); 484 + } 485 + if (filters.url?.isNotEmpty == true) { 486 + addChip('URL: ${filters.url}', onDeleted: () => _updatePostFilters(filters.copyWith(url: null))); 487 + } 488 + if (filters.since != null) { 489 + addChip( 490 + 'Since: ${formatTimestamp(filters.since!)}', 491 + onDeleted: () => _updatePostFilters(filters.copyWith(since: null)), 492 + ); 493 + } 494 + if (filters.until != null) { 495 + addChip( 496 + 'Until: ${formatTimestamp(filters.until!)}', 497 + onDeleted: () => _updatePostFilters(filters.copyWith(until: null)), 498 + ); 499 + } 500 + for (final tag in filters.tags) { 501 + addChip('#$tag', onDeleted: () => _removeTag(filters, tag)); 502 + } 503 + 504 + if (chips.isEmpty) { 505 + return const SizedBox.shrink(); 506 + } 507 + 508 + return Container( 509 + width: double.infinity, 510 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 511 + decoration: BoxDecoration( 512 + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), 513 + ), 514 + child: Column( 515 + crossAxisAlignment: CrossAxisAlignment.start, 516 + children: [ 517 + Wrap(spacing: 8, runSpacing: 6, children: chips), 518 + Align( 519 + alignment: Alignment.centerRight, 520 + child: TextButton( 521 + onPressed: () => _updatePostFilters( 522 + widget.fixedPostAuthor == null 523 + ? const PostSearchFilters() 524 + : PostSearchFilters(author: widget.fixedPostAuthor), 525 + ), 526 + child: const Text('Clear all'), 527 + ), 528 + ), 529 + ], 530 + ), 531 + ); 532 + } 533 + 534 + void _removeTag(PostSearchFilters filters, String tag) { 535 + final updatedTags = filters.tags.where((entry) => entry.toLowerCase() != tag.toLowerCase()).toList(growable: false); 536 + _updatePostFilters(filters.copyWith(tags: updatedTags)); 537 + } 538 + 539 + Future<void> _openPostFiltersSheet(PostSearchFilters currentFilters) async { 540 + final mentionsController = TextEditingController(text: currentFilters.mentions ?? ''); 541 + final authorController = TextEditingController(text: currentFilters.author ?? ''); 542 + final langController = TextEditingController(text: currentFilters.lang ?? ''); 543 + final domainController = TextEditingController(text: currentFilters.domain ?? ''); 544 + final urlController = TextEditingController(text: currentFilters.url ?? ''); 545 + final tagsController = TextEditingController(text: currentFilters.tags.join(', ')); 546 + DateTime? since = currentFilters.since; 547 + DateTime? until = currentFilters.until; 548 + 549 + Future<DateTime?> pickDateTime(DateTime? initial, {required bool isUntil}) async { 550 + final now = DateTime.now(); 551 + final initialDate = initial ?? now; 552 + final date = await showDatePicker( 553 + context: context, 554 + initialDate: initialDate, 555 + firstDate: DateTime(2000), 556 + lastDate: DateTime(now.year + 5), 557 + ); 558 + if (date == null || !mounted) { 559 + return initial; 560 + } 561 + 562 + final time = await showTimePicker(context: context, initialTime: TimeOfDay.fromDateTime(initialDate)); 563 + if (time == null) { 564 + final boundary = DateTime(date.year, date.month, date.day); 565 + return (isUntil ? boundary.add(const Duration(days: 1)) : boundary).toUtc(); 566 + } 567 + 568 + return DateTime(date.year, date.month, date.day, time.hour, time.minute).toUtc(); 569 + } 570 + 571 + await showModalBottomSheet<void>( 572 + context: context, 573 + isScrollControlled: true, 574 + builder: (sheetContext) { 575 + return StatefulBuilder( 576 + builder: (context, setState) { 577 + return Padding( 578 + padding: EdgeInsets.only( 579 + left: 16, 580 + right: 16, 581 + top: 16, 582 + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16, 583 + ), 584 + child: SingleChildScrollView( 585 + child: Column( 586 + mainAxisSize: MainAxisSize.min, 587 + crossAxisAlignment: CrossAxisAlignment.start, 588 + children: [ 589 + Text('Post filters', style: context.textTheme.titleMedium), 590 + const SizedBox(height: 12), 591 + TextField( 592 + controller: mentionsController, 593 + autocorrect: false, 594 + enableSuggestions: false, 595 + smartDashesType: SmartDashesType.disabled, 596 + smartQuotesType: SmartQuotesType.disabled, 597 + decoration: const InputDecoration(labelText: 'Mentions', hintText: 'did:plc:... or handle'), 598 + ), 599 + const SizedBox(height: 10), 600 + if (widget.fixedPostAuthor == null) ...[ 601 + TextField( 602 + controller: authorController, 603 + autocorrect: false, 604 + enableSuggestions: false, 605 + smartDashesType: SmartDashesType.disabled, 606 + smartQuotesType: SmartQuotesType.disabled, 607 + decoration: const InputDecoration(labelText: 'Author', hintText: 'did:plc:... or handle'), 608 + ), 609 + const SizedBox(height: 10), 610 + ] else ...[ 611 + InputDecorator( 612 + decoration: const InputDecoration(labelText: 'Author (fixed)'), 613 + child: Text(widget.fixedPostAuthor!), 614 + ), 615 + const SizedBox(height: 10), 616 + ], 617 + TextField( 618 + controller: langController, 619 + autocorrect: false, 620 + enableSuggestions: false, 621 + smartDashesType: SmartDashesType.disabled, 622 + smartQuotesType: SmartQuotesType.disabled, 623 + decoration: const InputDecoration(labelText: 'Language', hintText: 'en'), 624 + ), 625 + const SizedBox(height: 10), 626 + TextField( 627 + controller: domainController, 628 + autocorrect: false, 629 + enableSuggestions: false, 630 + smartDashesType: SmartDashesType.disabled, 631 + smartQuotesType: SmartQuotesType.disabled, 632 + decoration: const InputDecoration(labelText: 'Domain', hintText: 'example.com'), 633 + ), 634 + const SizedBox(height: 10), 635 + TextField( 636 + controller: urlController, 637 + autocorrect: false, 638 + enableSuggestions: false, 639 + smartDashesType: SmartDashesType.disabled, 640 + smartQuotesType: SmartQuotesType.disabled, 641 + decoration: const InputDecoration(labelText: 'URL', hintText: 'https://example.com/path'), 642 + ), 643 + const SizedBox(height: 10), 644 + TextField( 645 + controller: tagsController, 646 + autocorrect: false, 647 + enableSuggestions: false, 648 + smartDashesType: SmartDashesType.disabled, 649 + smartQuotesType: SmartQuotesType.disabled, 650 + decoration: const InputDecoration(labelText: 'Tags', hintText: '#dart, flutter'), 651 + ), 652 + const SizedBox(height: 12), 653 + Row( 654 + children: [ 655 + Expanded( 656 + child: OutlinedButton.icon( 657 + onPressed: () async { 658 + FocusScope.of(sheetContext).unfocus(); 659 + final selected = await pickDateTime(since, isUntil: false); 660 + if (selected == null) { 661 + return; 662 + } 663 + setState(() => since = selected); 664 + }, 665 + icon: const Icon(Icons.calendar_today, size: 16), 666 + label: Text(since == null ? 'Since' : formatTimestamp(since!)), 667 + ), 668 + ), 669 + const SizedBox(width: 8), 670 + Expanded( 671 + child: OutlinedButton.icon( 672 + onPressed: () async { 673 + FocusScope.of(sheetContext).unfocus(); 674 + final selected = await pickDateTime(until, isUntil: true); 675 + if (selected == null) { 676 + return; 677 + } 678 + setState(() => until = selected); 679 + }, 680 + icon: const Icon(Icons.calendar_today, size: 16), 681 + label: Text(until == null ? 'Until' : formatTimestamp(until!)), 682 + ), 683 + ), 684 + ], 685 + ), 686 + const SizedBox(height: 6), 687 + Row( 688 + children: [ 689 + TextButton(onPressed: () => setState(() => since = null), child: const Text('Clear since')), 690 + TextButton(onPressed: () => setState(() => until = null), child: const Text('Clear until')), 691 + ], 692 + ), 693 + const SizedBox(height: 12), 694 + Row( 695 + mainAxisAlignment: MainAxisAlignment.end, 696 + children: [ 697 + TextButton(onPressed: () => Navigator.of(sheetContext).pop(), child: const Text('Cancel')), 698 + TextButton( 699 + onPressed: () { 700 + FocusScope.of(sheetContext).unfocus(); 701 + _updatePostFilters( 702 + widget.fixedPostAuthor == null 703 + ? const PostSearchFilters() 704 + : PostSearchFilters(author: widget.fixedPostAuthor), 705 + ); 706 + Navigator.of(sheetContext).pop(); 707 + }, 708 + child: const Text('Clear all'), 709 + ), 710 + const SizedBox(width: 8), 711 + FilledButton( 712 + onPressed: () { 713 + FocusScope.of(sheetContext).unfocus(); 714 + final tags = tagsController.text 715 + .split(',') 716 + .map((value) => value.trim()) 717 + .where((value) => value.isNotEmpty) 718 + .toList(growable: false); 719 + final nextFilters = PostSearchFilters( 720 + mentions: mentionsController.text, 721 + author: widget.fixedPostAuthor ?? authorController.text, 722 + lang: langController.text, 723 + domain: domainController.text, 724 + url: urlController.text, 725 + tags: tags, 726 + since: since, 727 + until: until, 728 + ); 729 + _updatePostFilters(nextFilters); 730 + Navigator.of(sheetContext).pop(); 731 + }, 732 + child: const Text('Apply'), 733 + ), 734 + ], 735 + ), 736 + ], 737 + ), 738 + ), 739 + ); 740 + }, 741 + ); 742 + }, 743 + ); 744 + } 745 + 746 + void _updatePostFilters(PostSearchFilters filters) { 747 + try { 748 + context.read<SearchBloc>().add(PostFiltersChanged(filters: filters)); 749 + } on PostSearchValidationException catch (error) { 750 + showAppSnackBar(context, error.message); 751 + } 752 + } 753 + 411 754 Widget _buildBody(BuildContext context, SearchState state) { 412 755 if (state.currentTab == SearchTab.starterPacks) { 413 756 return _buildStarterPacksUnavailableState(context); 414 757 } 415 758 416 - if (state.query.isEmpty) { 759 + if (state.query.isEmpty && (state.currentTab != SearchTab.posts || state.postFilters.isEmpty)) { 417 760 return _buildSearchHistory(context, state); 418 761 } 419 762 ··· 457 800 Widget _buildSearchHistory(BuildContext context, SearchState state) { 458 801 final history = state.searchHistory; 459 802 if (history.isEmpty) { 460 - return _SearchEmptyState(tab: state.currentTab); 803 + return SearchEmptyState(tab: state.currentTab); 461 804 } 462 805 463 806 return Column( ··· 496 839 child: ListTile( 497 840 leading: const Icon(Icons.history), 498 841 title: Text(entry.query), 499 - subtitle: Text('$label · ${_formatHistoryTime(entry.searchedAt)}'), 842 + subtitle: Text( 843 + '$label · ${formatRelativeTime(entry.searchedAt, nowLabel: 'Just now', includeAgo: true)}', 844 + ), 500 845 onTap: () => _onHistoryTap(entry.query, entry.type), 501 846 ), 502 847 ); ··· 510 855 Widget _buildPostResults(BuildContext context, SearchState state) { 511 856 final posts = state.posts; 512 857 if (posts.isEmpty) { 513 - return _SearchNoResultsState(tab: SearchTab.posts, query: state.query); 858 + return SearchNoResultsState(tab: SearchTab.posts, query: state.query); 514 859 } 515 860 516 861 return ListView.builder( ··· 536 881 Widget _buildActorResults(BuildContext context, SearchState state) { 537 882 final actors = state.actors; 538 883 if (actors.isEmpty) { 539 - return _SearchNoResultsState(tab: SearchTab.actors, query: state.query); 884 + return SearchNoResultsState(tab: SearchTab.actors, query: state.query); 540 885 } 541 886 542 887 return ListView.builder( ··· 562 907 Widget _buildFeedResults(BuildContext context, SearchState state) { 563 908 final feeds = state.feeds; 564 909 if (feeds.isEmpty) { 565 - return _SearchNoResultsState(tab: SearchTab.feeds, query: state.query); 910 + return SearchNoResultsState(tab: SearchTab.feeds, query: state.query); 566 911 } 567 912 568 913 return ListView.builder( ··· 599 944 Widget _buildStarterPackResults(BuildContext context, SearchState state) { 600 945 final packs = state.starterPacks; 601 946 if (packs.isEmpty) { 602 - return _SearchNoResultsState(tab: SearchTab.starterPacks, query: state.query); 947 + return SearchNoResultsState(tab: SearchTab.starterPacks, query: state.query); 603 948 } 604 949 605 950 return ListView.builder( ··· 630 975 ); 631 976 } 632 977 633 - String _formatHistoryTime(DateTime time) { 634 - return formatRelativeTime(time, nowLabel: 'Just now', includeAgo: true); 635 - } 636 - 637 978 String _searchPlaceholderForTab(SearchTab tab) => switch (tab) { 638 - SearchTab.posts => 'Search posts', 639 - SearchTab.actors => 'Search people', 640 - SearchTab.feeds => 'Search feeds', 641 - SearchTab.starterPacks => 'Starter pack search unavailable', 979 + SearchTab.posts => widget.postsOnlyMode ? 'Search this profile\'s posts' : 'Search posts', 980 + _ => tab.placeholder, 642 981 }; 643 982 644 - Widget _buildStarterPacksUnavailableState(BuildContext context) { 645 - return Center( 646 - child: Padding( 647 - padding: const EdgeInsets.all(24), 648 - child: Column( 649 - mainAxisSize: MainAxisSize.min, 650 - children: [ 651 - Icon(Icons.info_outline, size: 52, color: context.colorScheme.outline), 652 - const SizedBox(height: 16), 653 - Text( 654 - 'Starter Pack Search Is Unavailable', 655 - style: context.textTheme.titleMedium, 656 - textAlign: TextAlign.center, 657 - ), 658 - const SizedBox(height: 8), 659 - Text( 660 - '(Starter Pack Search is not yet implemented in the BlueSky API)', 661 - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), 662 - textAlign: TextAlign.center, 663 - ), 664 - const SizedBox(height: 14), 665 - TextButton.icon( 666 - onPressed: _openStarterPackIssue, 667 - icon: const Icon(Icons.open_in_new), 668 - label: const Text('Track API progress'), 669 - ), 670 - ], 671 - ), 983 + Widget _buildStarterPacksUnavailableState(BuildContext context) => Center( 984 + child: Padding( 985 + padding: const EdgeInsets.all(24), 986 + child: Column( 987 + mainAxisSize: MainAxisSize.min, 988 + children: [ 989 + Icon(Icons.info_outline, size: 52, color: context.colorScheme.outline), 990 + const SizedBox(height: 16), 991 + Text('Starter Pack Search Is Unavailable', style: context.textTheme.titleMedium, textAlign: TextAlign.center), 992 + const SizedBox(height: 8), 993 + Text( 994 + '(Starter Pack Search is not yet implemented in the BlueSky API)', 995 + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), 996 + textAlign: TextAlign.center, 997 + ), 998 + const SizedBox(height: 14), 999 + TextButton.icon( 1000 + onPressed: _openStarterPackIssue, 1001 + icon: const Icon(Icons.open_in_new), 1002 + label: const Text('Track API progress'), 1003 + ), 1004 + ], 672 1005 ), 673 - ); 674 - } 1006 + ), 1007 + ); 675 1008 676 1009 Future<void> _openStarterPackIssue() async { 677 1010 final launched = await launchUrl(_starterPackSearchIssueUri, mode: LaunchMode.externalApplication); ··· 688 1021 689 1022 @override 690 1023 Widget build(BuildContext context) { 691 - final record = _tryParseRecord(post.record); 1024 + final record = tryParseRecord(post.record); 692 1025 final createdAt = record?.createdAt ?? post.indexedAt; 693 1026 final moderationService = maybeModerationService(context); 694 1027 final postUi = ··· 727 1060 moderationService?.profileBasicUi(author, bsky_moderation.ModerationBehaviorContext.avatar) ?? 728 1061 const bsky_moderation.ModerationUI(); 729 1062 return InkWell( 730 - onTap: () => _navigateToProfile(context, author.did), 1063 + onTap: () => navigateToProfile(context, author.did), 731 1064 child: Row( 732 1065 crossAxisAlignment: CrossAxisAlignment.start, 733 1066 children: [ ··· 794 1127 ), 795 1128 ); 796 1129 } 797 - 798 - void _navigateToProfile(BuildContext context, String did) { 799 - navigateToProfile(context, did); 800 - } 801 - 802 - FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 803 - try { 804 - return FeedPostRecord.fromJson(record); 805 - } catch (_) { 806 - return null; 807 - } 808 - } 809 1130 } 810 1131 811 1132 class _ActorResultTile extends StatelessWidget { ··· 824 1145 const bsky_moderation.ModerationUI(); 825 1146 826 1147 return InkWell( 827 - onTap: () => _navigateToProfile(context, actor.did), 1148 + onTap: () => navigateToProfile(context, actor.did), 828 1149 child: Container( 829 1150 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 830 1151 decoration: BoxDecoration( ··· 876 1197 ), 877 1198 ), 878 1199 ); 879 - } 880 - 881 - void _navigateToProfile(BuildContext context, String did) { 882 - navigateToProfile(context, did); 883 1200 } 884 1201 } 885 1202 ··· 1029 1346 setState(() => _isFollowing = !_isFollowing); 1030 1347 } 1031 1348 } 1032 - 1033 - class _SearchEmptyState extends StatelessWidget { 1034 - const _SearchEmptyState({required this.tab}); 1035 - 1036 - final SearchTab tab; 1037 - 1038 - @override 1039 - Widget build(BuildContext context) { 1040 - final (title, message) = switch (tab) { 1041 - SearchTab.posts => ( 1042 - 'Search posts', 1043 - 'Find conversations and keywords across posts.\nUse Jump to profile to quickly open a user.', 1044 - ), 1045 - SearchTab.actors => ( 1046 - 'Search people', 1047 - 'Look up accounts by handle or name.\nUse Jump to profile when you know the exact handle.', 1048 - ), 1049 - SearchTab.feeds => ('Search feeds', 'Discover custom feeds by topic, creator, or keyword.'), 1050 - SearchTab.starterPacks => ('Search starter packs', 'Find curated starter packs to discover accounts and feeds.'), 1051 - }; 1052 - 1053 - return Center( 1054 - child: Padding( 1055 - padding: const EdgeInsets.all(24), 1056 - child: Column( 1057 - mainAxisSize: MainAxisSize.min, 1058 - children: [ 1059 - Icon(Icons.search, size: 64, color: context.colorScheme.outline), 1060 - const SizedBox(height: 16), 1061 - Text(title, style: context.textTheme.titleMedium), 1062 - const SizedBox(height: 8), 1063 - Text( 1064 - message, 1065 - textAlign: TextAlign.center, 1066 - style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.outline), 1067 - ), 1068 - ], 1069 - ), 1070 - ), 1071 - ); 1072 - } 1073 - } 1074 - 1075 - class _SearchNoResultsState extends StatelessWidget { 1076 - const _SearchNoResultsState({required this.tab, required this.query}); 1077 - 1078 - final SearchTab tab; 1079 - final String query; 1080 - 1081 - @override 1082 - Widget build(BuildContext context) { 1083 - final safeQuery = query.trim(); 1084 - final (title, message) = switch (tab) { 1085 - SearchTab.posts => ( 1086 - 'No posts found', 1087 - 'Try broader keywords or a shorter phrase${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1088 - ), 1089 - SearchTab.actors => ( 1090 - 'No people found', 1091 - 'Try a handle, display name, or fewer terms${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1092 - ), 1093 - SearchTab.feeds => ( 1094 - 'No feeds found', 1095 - 'Try searching by topic or feed creator${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1096 - ), 1097 - SearchTab.starterPacks => ( 1098 - 'No starter packs found', 1099 - 'Try another topic or broader keyword${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 1100 - ), 1101 - }; 1102 - 1103 - return Center( 1104 - child: Padding( 1105 - padding: const EdgeInsets.symmetric(horizontal: 24), 1106 - child: Column( 1107 - mainAxisSize: MainAxisSize.min, 1108 - children: [ 1109 - Icon(Icons.search_off_rounded, size: 44, color: context.colorScheme.outline), 1110 - const SizedBox(height: 12), 1111 - Text(title, style: context.textTheme.bodyLarge), 1112 - const SizedBox(height: 6), 1113 - Text( 1114 - message, 1115 - textAlign: TextAlign.center, 1116 - style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 1117 - ), 1118 - ], 1119 - ), 1120 - ), 1121 - ); 1122 - } 1123 - }
+95
lib/features/search/presentation/widgets/search_result_states.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:lazurite/core/theme/theme_extensions.dart'; 3 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 4 + 5 + class SearchEmptyState extends StatelessWidget { 6 + const SearchEmptyState({super.key, required this.tab}); 7 + 8 + final SearchTab tab; 9 + 10 + @override 11 + Widget build(BuildContext context) { 12 + final (title, message) = switch (tab) { 13 + SearchTab.posts => ( 14 + 'Search posts', 15 + 'Find conversations and keywords across posts.\nUse Jump to profile to quickly open a user.', 16 + ), 17 + SearchTab.actors => ( 18 + 'Search people', 19 + 'Look up accounts by handle or name.\nUse Jump to profile when you know the exact handle.', 20 + ), 21 + SearchTab.feeds => ('Search feeds', 'Discover custom feeds by topic, creator, or keyword.'), 22 + SearchTab.starterPacks => ('Search starter packs', 'Find curated starter packs to discover accounts and feeds.'), 23 + }; 24 + 25 + return Center( 26 + child: Padding( 27 + padding: const EdgeInsets.all(24), 28 + child: Column( 29 + mainAxisSize: MainAxisSize.min, 30 + children: [ 31 + Icon(Icons.search, size: 64, color: context.colorScheme.outline), 32 + const SizedBox(height: 16), 33 + Text(title, style: context.textTheme.titleMedium), 34 + const SizedBox(height: 8), 35 + Text( 36 + message, 37 + textAlign: TextAlign.center, 38 + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.outline), 39 + ), 40 + ], 41 + ), 42 + ), 43 + ); 44 + } 45 + } 46 + 47 + class SearchNoResultsState extends StatelessWidget { 48 + const SearchNoResultsState({super.key, required this.tab, required this.query}); 49 + 50 + final SearchTab tab; 51 + final String query; 52 + 53 + @override 54 + Widget build(BuildContext context) { 55 + final safeQuery = query.trim(); 56 + final (title, message) = switch (tab) { 57 + SearchTab.posts => ( 58 + 'No posts found', 59 + 'Try broader keywords or a shorter phrase${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 60 + ), 61 + SearchTab.actors => ( 62 + 'No people found', 63 + 'Try a handle, display name, or fewer terms${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 64 + ), 65 + SearchTab.feeds => ( 66 + 'No feeds found', 67 + 'Try searching by topic or feed creator${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 68 + ), 69 + SearchTab.starterPacks => ( 70 + 'No starter packs found', 71 + 'Try another topic or broader keyword${safeQuery.isEmpty ? '' : ' for "$safeQuery"'}.', 72 + ), 73 + }; 74 + 75 + return Center( 76 + child: Padding( 77 + padding: const EdgeInsets.symmetric(horizontal: 24), 78 + child: Column( 79 + mainAxisSize: MainAxisSize.min, 80 + children: [ 81 + Icon(Icons.search_off_rounded, size: 44, color: context.colorScheme.outline), 82 + const SizedBox(height: 12), 83 + Text(title, style: context.textTheme.bodyLarge), 84 + const SizedBox(height: 6), 85 + Text( 86 + message, 87 + textAlign: TextAlign.center, 88 + style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 89 + ), 90 + ], 91 + ), 92 + ), 93 + ); 94 + } 95 + }
+2 -9
lib/features/settings/presentation/settings_screen.dart
··· 19 19 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 20 20 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 21 21 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 22 + import 'package:lazurite/shared/utils/format_utils.dart'; 22 23 23 24 class SettingsScreen extends StatelessWidget { 24 25 const SettingsScreen({super.key}); ··· 461 462 label: 'Last Health Check', 462 463 value: state.appViewHealthCheckedAt == null 463 464 ? 'Never' 464 - : _formatTimestamp(state.appViewHealthCheckedAt!.toLocal()), 465 + : formatTimestamp(state.appViewHealthCheckedAt!.toLocal()), 465 466 ), 466 467 const Divider(height: 1), 467 468 _ConnectionDetailRow(label: 'Last Fallback', value: state.appViewLastFallback ?? 'None'), ··· 491 492 String _appViewSubtitle(String providerKey) { 492 493 final provider = AppViewProviders.providerDisplayName(providerKey); 493 494 return '$provider selected. Switching providers performs a soft restart.'; 494 - } 495 - 496 - String _formatTimestamp(DateTime time) { 497 - final month = time.month.toString().padLeft(2, '0'); 498 - final day = time.day.toString().padLeft(2, '0'); 499 - final hour = time.hour.toString().padLeft(2, '0'); 500 - final minute = time.minute.toString().padLeft(2, '0'); 501 - return '${time.year}-$month-$day $hour:$minute'; 502 495 } 503 496 504 497 Future<void> _confirmAndApplyProviderChange(BuildContext context, String selectedProvider) async {
+8
lib/shared/utils/format_utils.dart
··· 50 50 51 51 return uppercase ? formatted.toUpperCase() : formatted; 52 52 } 53 + 54 + String formatTimestamp(DateTime time) { 55 + final month = time.month.toString().padLeft(2, '0'); 56 + final day = time.day.toString().padLeft(2, '0'); 57 + final hour = time.hour.toString().padLeft(2, '0'); 58 + final minute = time.minute.toString().padLeft(2, '0'); 59 + return '${time.year}-$month-$day $hour:$minute'; 60 + }
+9
lib/shared/utils/parse_utils.dart
··· 1 + import 'package:bluesky/app_bsky_feed_post.dart'; 2 + 3 + FeedPostRecord? tryParseRecord(Map<String, dynamic> record) { 4 + try { 5 + return FeedPostRecord.fromJson(record); 6 + } catch (_) { 7 + return null; 8 + } 9 + }
+40 -1
test/core/router/app_router_test.dart
··· 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:lazurite/core/database/app_database.dart'; 8 9 import 'package:lazurite/core/router/app_router.dart'; 9 10 import 'package:lazurite/core/theme/app_theme.dart'; 10 11 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; ··· 17 18 import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; 18 19 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 19 20 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 21 + import 'package:lazurite/features/search/data/search_repository.dart'; 20 22 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 21 23 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 24 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 22 25 import 'package:mocktail/mocktail.dart'; 23 26 24 27 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} ··· 41 44 42 45 class MockNotificationRepository extends Mock implements NotificationRepository {} 43 46 47 + class MockSearchRepository extends Mock implements SearchRepository {} 48 + 49 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 50 + 51 + class MockAppDatabase extends Mock implements AppDatabase {} 52 + 44 53 void main() { 45 54 late MockAuthBloc authBloc; 46 55 late MockFeedPreferencesCubit feedPreferencesCubit; ··· 52 61 late MockUnreadCountCubit unreadCountCubit; 53 62 late MockConvoListBloc convoListBloc; 54 63 late MockNotificationRepository notificationRepository; 64 + late MockSearchRepository searchRepository; 65 + late MockTypeaheadRepository typeaheadRepository; 66 + late MockAppDatabase database; 55 67 late StreamController<AuthState> authController; 56 68 late AuthState currentAuthState; 57 69 ··· 84 96 unreadCountCubit = MockUnreadCountCubit(); 85 97 convoListBloc = MockConvoListBloc(); 86 98 notificationRepository = MockNotificationRepository(); 99 + searchRepository = MockSearchRepository(); 100 + typeaheadRepository = MockTypeaheadRepository(); 101 + database = MockAppDatabase(); 87 102 authController = StreamController<AuthState>.broadcast(); 88 103 currentAuthState = const AuthState.authenticated(tokens); 89 104 ··· 168 183 ], 169 184 child: RepositoryProvider<NotificationRepository>( 170 185 create: (_) => notificationRepository, 171 - child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 186 + child: MultiRepositoryProvider( 187 + providers: [ 188 + RepositoryProvider<SearchRepository>.value(value: searchRepository), 189 + RepositoryProvider<TypeaheadRepository>.value(value: typeaheadRepository), 190 + RepositoryProvider<AppDatabase>.value(value: database), 191 + RepositoryProvider<String>.value(value: tokens.did), 192 + ], 193 + child: MaterialApp.router(routerConfig: AppRouter(authBloc: authBloc).router), 194 + ), 172 195 ), 173 196 ); 174 197 ··· 252 275 expect(find.text('No feeds pinned'), findsOneWidget); 253 276 final navBar = tester.widget<NavigationBar>(find.byType(NavigationBar)); 254 277 expect(navBar.selectedIndex, 0); 278 + }); 279 + 280 + testWidgets('profile search action opens profile-scoped post search route', (tester) async { 281 + await tester.binding.setSurfaceSize(const Size(430, 932)); 282 + addTearDown(() => tester.binding.setSurfaceSize(null)); 283 + 284 + await tester.pumpWidget(buildSubject()); 285 + await tester.pumpAndSettle(); 286 + 287 + await tester.tap(find.text('PROFILE').last); 288 + await tester.pumpAndSettle(); 289 + 290 + await tester.tap(find.byKey(const Key('profile_search_posts_button'))); 291 + await tester.pumpAndSettle(); 292 + 293 + expect(find.text('Search @me.bsky.social'), findsOneWidget); 255 294 }); 256 295 257 296 testWidgets('Android back at non-Home tab root switches to Home tab', (tester) async {
+65
test/features/profile/presentation/profile_screen_test.dart
··· 976 976 router.dispose(); 977 977 }); 978 978 }); 979 + 980 + group('Profile post search action', () { 981 + testWidgets('search icon navigates to profile-scoped post search route', (tester) async { 982 + useLargeScreen(tester); 983 + const otherProfile = ProfileViewDetailed( 984 + did: 'did:plc:other', 985 + handle: 'other.bsky.social', 986 + displayName: 'Other User', 987 + ); 988 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); 989 + whenListen( 990 + profileBloc, 991 + const Stream<ProfileState>.empty(), 992 + initialState: const ProfileState.loaded(profile: otherProfile), 993 + ); 994 + when(() => feedBloc.state).thenReturn( 995 + const FeedState.loaded(actor: 'did:plc:other', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 996 + ); 997 + whenListen( 998 + feedBloc, 999 + const Stream<FeedState>.empty(), 1000 + initialState: const FeedState.loaded( 1001 + actor: 'did:plc:other', 1002 + posts: [], 1003 + filter: FeedFilter.postsNoReplies, 1004 + hasMore: false, 1005 + ), 1006 + ); 1007 + 1008 + final router = GoRouter( 1009 + routes: [ 1010 + GoRoute( 1011 + path: '/', 1012 + builder: (context, state) => MultiRepositoryProvider( 1013 + providers: [RepositoryProvider<ProfileActionRepository>.value(value: MockProfileActionRepository())], 1014 + child: MultiBlocProvider( 1015 + providers: [ 1016 + BlocProvider<AuthBloc>.value(value: authBloc), 1017 + BlocProvider<ProfileBloc>.value(value: profileBloc), 1018 + BlocProvider<FeedBloc>.value(value: feedBloc), 1019 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 1020 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 1021 + ], 1022 + child: const ProfileScreen(actor: 'did:plc:other', showBackButton: true), 1023 + ), 1024 + ), 1025 + ), 1026 + GoRoute( 1027 + path: '/profile/:actor/search-posts', 1028 + builder: (context, state) => Text('search:${state.pathParameters['actor']}'), 1029 + ), 1030 + ], 1031 + ); 1032 + 1033 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 1034 + await tester.pumpAndSettle(); 1035 + 1036 + await tester.tap(find.byKey(const Key('profile_search_posts_button'))); 1037 + await tester.pumpAndSettle(); 1038 + 1039 + expect(find.text('search:other.bsky.social'), findsOneWidget); 1040 + 1041 + router.dispose(); 1042 + }); 1043 + }); 979 1044 }
+58
test/features/profile/presentation/widgets/profile_liked_posts_pane_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 4 + import 'package:lazurite/features/profile/presentation/widgets/profile_liked_posts_pane.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockProfileRepository extends Mock implements ProfileRepository {} 8 + 9 + void main() { 10 + late MockProfileRepository repository; 11 + 12 + setUp(() { 13 + repository = MockProfileRepository(); 14 + }); 15 + 16 + testWidgets('shows empty state when actor has no liked posts', (tester) async { 17 + when( 18 + () => repository.getActorLikes( 19 + actor: any(named: 'actor'), 20 + cursor: any(named: 'cursor'), 21 + limit: any(named: 'limit'), 22 + ), 23 + ).thenAnswer((_) async => const ProfileActorLikesResult(entries: [], cursor: null)); 24 + 25 + await tester.pumpWidget( 26 + MaterialApp( 27 + home: Scaffold( 28 + body: ProfileLikedPostsPane(actor: 'did:plc:actor', profileRepository: repository), 29 + ), 30 + ), 31 + ); 32 + await tester.pumpAndSettle(); 33 + 34 + expect(find.text('No liked posts yet'), findsOneWidget); 35 + }); 36 + 37 + testWidgets('shows retry when liked posts load fails', (tester) async { 38 + when( 39 + () => repository.getActorLikes( 40 + actor: any(named: 'actor'), 41 + cursor: any(named: 'cursor'), 42 + limit: any(named: 'limit'), 43 + ), 44 + ).thenThrow(Exception('boom')); 45 + 46 + await tester.pumpWidget( 47 + MaterialApp( 48 + home: Scaffold( 49 + body: ProfileLikedPostsPane(actor: 'did:plc:actor', profileRepository: repository), 50 + ), 51 + ), 52 + ); 53 + await tester.pumpAndSettle(); 54 + 55 + expect(find.textContaining('Failed to load liked posts'), findsOneWidget); 56 + expect(find.text('Retry'), findsOneWidget); 57 + }); 58 + }
+58
test/features/profile/presentation/widgets/profile_starter_packs_pane_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/profile/presentation/widgets/profile_starter_packs_pane.dart'; 4 + import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockStarterPackRepository extends Mock implements StarterPackRepository {} 8 + 9 + void main() { 10 + late MockStarterPackRepository repository; 11 + 12 + setUp(() { 13 + repository = MockStarterPackRepository(); 14 + }); 15 + 16 + testWidgets('shows empty state when actor has no starter packs', (tester) async { 17 + when( 18 + () => repository.getActorStarterPacks( 19 + actor: any(named: 'actor'), 20 + cursor: any(named: 'cursor'), 21 + limit: any(named: 'limit'), 22 + ), 23 + ).thenAnswer((_) async => const ActorStarterPacksResult(starterPacks: [], cursor: null)); 24 + 25 + await tester.pumpWidget( 26 + MaterialApp( 27 + home: Scaffold( 28 + body: ProfileStarterPacksPane(actor: 'did:plc:actor', starterPackRepository: repository), 29 + ), 30 + ), 31 + ); 32 + await tester.pumpAndSettle(); 33 + 34 + expect(find.text('No starter packs yet'), findsOneWidget); 35 + }); 36 + 37 + testWidgets('shows retry state when starter packs load fails', (tester) async { 38 + when( 39 + () => repository.getActorStarterPacks( 40 + actor: any(named: 'actor'), 41 + cursor: any(named: 'cursor'), 42 + limit: any(named: 'limit'), 43 + ), 44 + ).thenThrow(Exception('boom')); 45 + 46 + await tester.pumpWidget( 47 + MaterialApp( 48 + home: Scaffold( 49 + body: ProfileStarterPacksPane(actor: 'did:plc:actor', starterPackRepository: repository), 50 + ), 51 + ), 52 + ); 53 + await tester.pumpAndSettle(); 54 + 55 + expect(find.textContaining('Failed to load starter packs'), findsOneWidget); 56 + expect(find.text('Retry'), findsOneWidget); 57 + }); 58 + }
+176
test/features/search/bloc/search_bloc_test.dart
··· 6 6 import 'package:flutter_test/flutter_test.dart'; 7 7 import 'package:lazurite/core/database/app_database.dart'; 8 8 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 9 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 9 10 import 'package:lazurite/features/search/data/search_repository.dart'; 10 11 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 11 12 import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; ··· 62 63 indexedAt: DateTime.utc(2026, 1, 1), 63 64 ); 64 65 66 + setUpAll(() { 67 + registerFallbackValue(const PostSearchFilters()); 68 + }); 69 + 65 70 setUp(() { 66 71 mockRepository = MockSearchRepository(); 67 72 mockTypeaheadRepository = MockTypeaheadRepository(); ··· 79 84 () => mockRepository.searchPosts( 80 85 query: any(named: 'query'), 81 86 sort: any(named: 'sort'), 87 + filters: any(named: 'filters'), 82 88 cursor: any(named: 'cursor'), 83 89 limit: any(named: 'limit'), 84 90 ), ··· 119 125 accountDid: 'did:plc:test', 120 126 ); 121 127 128 + SearchBloc buildScopedBloc() => SearchBloc( 129 + searchRepository: mockRepository, 130 + typeaheadRepository: mockTypeaheadRepository, 131 + database: mockDatabase, 132 + accountDid: 'did:plc:test', 133 + config: const SearchBlocConfig.profileScoped(fixedPostAuthor: 'did:plc:scoped'), 134 + ); 135 + 122 136 group('SearchTab enum', () { 123 137 test('has posts, actors, feeds, and starterPacks values', () { 124 138 expect( ··· 326 340 () => mockRepository.searchPosts( 327 341 query: 'flutter', 328 342 sort: any(named: 'sort'), 343 + filters: any(named: 'filters'), 329 344 cursor: any(named: 'cursor'), 330 345 limit: any(named: 'limit'), 331 346 ), ··· 458 473 ); 459 474 }); 460 475 476 + group('Post filters behavior', () { 477 + blocTest<SearchBloc, SearchState>( 478 + 'filter change triggers post search with active filters', 479 + build: buildBloc, 480 + act: (bloc) => bloc.add(const PostFiltersChanged(filters: PostSearchFilters(domain: 'example.com'))), 481 + verify: (_) { 482 + final captured = verify( 483 + () => mockRepository.searchPosts( 484 + query: any(named: 'query'), 485 + sort: any(named: 'sort'), 486 + filters: captureAny(named: 'filters'), 487 + cursor: any(named: 'cursor'), 488 + limit: any(named: 'limit'), 489 + ), 490 + ).captured; 491 + final filters = captured.last as PostSearchFilters; 492 + expect(filters.domain, 'example.com'); 493 + }, 494 + ); 495 + 496 + blocTest<SearchBloc, SearchState>( 497 + 'invalid since/until emits error state', 498 + build: buildBloc, 499 + act: (bloc) => bloc.add( 500 + PostFiltersChanged( 501 + filters: PostSearchFilters(since: DateTime.utc(2026, 1, 2), until: DateTime.utc(2026, 1, 1)), 502 + ), 503 + ), 504 + expect: () => [ 505 + predicate<SearchState>((s) => s.status == SearchStatus.error && (s.errorMessage?.contains('Since') ?? false)), 506 + ], 507 + ); 508 + 509 + blocTest<SearchBloc, SearchState>( 510 + 'load more reuses active filters', 511 + build: buildBloc, 512 + seed: () => SearchState.loadedPosts( 513 + query: 'flutter', 514 + sort: 'top', 515 + postFilters: const PostSearchFilters(domain: 'example.com'), 516 + posts: [samplePost], 517 + cursor: 'next-page', 518 + ), 519 + act: (bloc) => bloc.add(const LoadMoreRequested()), 520 + verify: (_) { 521 + final captured = verify( 522 + () => mockRepository.searchPosts( 523 + query: 'flutter', 524 + sort: 'top', 525 + filters: captureAny(named: 'filters'), 526 + cursor: 'next-page', 527 + limit: 50, 528 + ), 529 + ).captured; 530 + final filters = captured.last as PostSearchFilters; 531 + expect(filters.domain, 'example.com'); 532 + }, 533 + ); 534 + 535 + blocTest<SearchBloc, SearchState>( 536 + 'profile-scoped mode enforces fixed author', 537 + build: buildScopedBloc, 538 + act: (bloc) => bloc.add(const PostFiltersChanged(filters: PostSearchFilters(author: 'did:plc:other'))), 539 + verify: (_) { 540 + final captured = verify( 541 + () => mockRepository.searchPosts( 542 + query: any(named: 'query'), 543 + sort: any(named: 'sort'), 544 + filters: captureAny(named: 'filters'), 545 + cursor: any(named: 'cursor'), 546 + limit: any(named: 'limit'), 547 + ), 548 + ).captured; 549 + final filters = captured.last as PostSearchFilters; 550 + expect(filters.author, 'did:plc:scoped'); 551 + }, 552 + ); 553 + }); 554 + 555 + group('Profile-scoped post search bootstrap', () { 556 + blocTest<SearchBloc, SearchState>( 557 + 'auto-loads profile posts on initialization using latest sort and fixed author', 558 + build: buildScopedBloc, 559 + wait: const Duration(milliseconds: 10), 560 + expect: () => [ 561 + predicate<SearchState>( 562 + (s) => 563 + s.status == SearchStatus.loading && 564 + s.currentTab == SearchTab.posts && 565 + s.currentSort == 'latest' && 566 + s.query.isEmpty, 567 + ), 568 + predicate<SearchState>( 569 + (s) => 570 + s.status == SearchStatus.loaded && 571 + s.currentTab == SearchTab.posts && 572 + s.currentSort == 'latest' && 573 + s.query.isEmpty, 574 + ), 575 + ], 576 + verify: (_) { 577 + final captured = verify( 578 + () => mockRepository.searchPosts( 579 + query: captureAny(named: 'query'), 580 + sort: captureAny(named: 'sort'), 581 + filters: captureAny(named: 'filters'), 582 + cursor: any(named: 'cursor'), 583 + limit: any(named: 'limit'), 584 + ), 585 + ).captured; 586 + expect(captured[0], ''); 587 + expect(captured[1], 'latest'); 588 + final filters = captured[2] as PostSearchFilters; 589 + expect(filters.author, 'did:plc:scoped'); 590 + }, 591 + ); 592 + 593 + blocTest<SearchBloc, SearchState>( 594 + 'query cleared re-loads profile posts in scoped mode', 595 + build: buildScopedBloc, 596 + wait: const Duration(milliseconds: 10), 597 + skip: 2, 598 + act: (bloc) => bloc.add(const QueryCleared()), 599 + expect: () => [ 600 + predicate<SearchState>( 601 + (s) => 602 + s.status == SearchStatus.initial && 603 + s.currentTab == SearchTab.posts && 604 + s.currentSort == 'latest' && 605 + s.postFilters.author == 'did:plc:scoped', 606 + ), 607 + predicate<SearchState>( 608 + (s) => 609 + s.status == SearchStatus.loading && 610 + s.currentTab == SearchTab.posts && 611 + s.currentSort == 'latest' && 612 + s.query.isEmpty, 613 + ), 614 + predicate<SearchState>( 615 + (s) => 616 + s.status == SearchStatus.loaded && 617 + s.currentTab == SearchTab.posts && 618 + s.currentSort == 'latest' && 619 + s.query.isEmpty, 620 + ), 621 + ], 622 + verify: (_) { 623 + verify( 624 + () => mockRepository.searchPosts( 625 + query: '', 626 + sort: 'latest', 627 + filters: any(named: 'filters'), 628 + cursor: any(named: 'cursor'), 629 + limit: any(named: 'limit'), 630 + ), 631 + ).called(2); 632 + }, 633 + ); 634 + }); 635 + 461 636 group('Existing tab behavior unaffected', () { 462 637 blocTest<SearchBloc, SearchState>( 463 638 'searches posts when posts tab is active', ··· 467 642 () => mockRepository.searchPosts( 468 643 query: 'hello', 469 644 sort: any(named: 'sort'), 645 + filters: any(named: 'filters'), 470 646 cursor: any(named: 'cursor'), 471 647 limit: any(named: 'limit'), 472 648 ),
+49
test/features/search/data/post_search_filters_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 3 + 4 + void main() { 5 + group('PostSearchFilters', () { 6 + test('normalizes trims and deduplicates tags', () { 7 + const filters = PostSearchFilters( 8 + mentions: ' did:plc:alice ', 9 + author: ' alice.bsky.social ', 10 + tags: [' #dart ', 'flutter', '#Dart', ''], 11 + ); 12 + 13 + final normalized = filters.normalized(); 14 + 15 + expect(normalized.mentions, 'did:plc:alice'); 16 + expect(normalized.author, 'alice.bsky.social'); 17 + expect(normalized.tags, ['dart', 'flutter']); 18 + }); 19 + 20 + test('fixed author overrides typed author', () { 21 + const filters = PostSearchFilters(author: 'alice.bsky.social'); 22 + final normalized = filters.normalized(fixedAuthor: 'did:plc:fixed'); 23 + expect(normalized.author, 'did:plc:fixed'); 24 + }); 25 + 26 + test('throws when since is after until', () { 27 + final filters = PostSearchFilters(since: DateTime.utc(2026, 1, 2), until: DateTime.utc(2026, 1, 1)); 28 + 29 + expect(() => filters.normalized(), throwsA(isA<PostSearchValidationException>())); 30 + }); 31 + }); 32 + 33 + group('PostSearchRequest', () { 34 + test('throws for no-op request', () { 35 + const request = PostSearchRequest(query: ' ', filters: PostSearchFilters()); 36 + expect(() => request.normalized(), throwsA(isA<PostSearchValidationException>())); 37 + }); 38 + 39 + test('accepts filter-only request and normalizes query to empty', () { 40 + const request = PostSearchRequest( 41 + query: ' ', 42 + filters: PostSearchFilters(author: 'alice.bsky.social'), 43 + ); 44 + final normalized = request.normalized(); 45 + expect(normalized.query, ''); 46 + expect(normalized.filters.author, 'alice.bsky.social'); 47 + }); 48 + }); 49 + }
+154
test/features/search/data/search_repository_post_filters_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_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_searchposts.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 7 + import 'package:lazurite/features/search/data/search_repository.dart'; 8 + 9 + class _FakeResponse<T> { 10 + _FakeResponse(this.data); 11 + 12 + final T data; 13 + } 14 + 15 + class _FakeSearchPostsData { 16 + _FakeSearchPostsData({required this.posts, this.cursor, this.hitsTotal}); 17 + 18 + final List<PostView> posts; 19 + final String? cursor; 20 + final int? hitsTotal; 21 + } 22 + 23 + class _FakeFeedService { 24 + String? lastQ; 25 + FeedSearchPostsSort? lastSort; 26 + String? lastSince; 27 + String? lastUntil; 28 + String? lastMentions; 29 + String? lastAuthor; 30 + String? lastLang; 31 + String? lastDomain; 32 + String? lastUrl; 33 + List<String>? lastTags; 34 + String? lastCursor; 35 + int? lastLimit; 36 + 37 + Future<_FakeResponse<_FakeSearchPostsData>> searchPosts({ 38 + required String q, 39 + FeedSearchPostsSort? sort, 40 + String? since, 41 + String? until, 42 + String? mentions, 43 + String? author, 44 + String? lang, 45 + String? domain, 46 + String? url, 47 + List<String>? tag, 48 + String? cursor, 49 + int? limit, 50 + Map<String, String>? $headers, 51 + }) async { 52 + lastQ = q; 53 + lastSort = sort; 54 + lastSince = since; 55 + lastUntil = until; 56 + lastMentions = mentions; 57 + lastAuthor = author; 58 + lastLang = lang; 59 + lastDomain = domain; 60 + lastUrl = url; 61 + lastTags = tag; 62 + lastCursor = cursor; 63 + lastLimit = limit; 64 + 65 + return _FakeResponse( 66 + _FakeSearchPostsData( 67 + posts: [ 68 + PostView( 69 + uri: AtUri.parse('at://did:plc:test/app.bsky.feed.post/1'), 70 + cid: 'cid1', 71 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 72 + record: const {r'$type': 'app.bsky.feed.post', 'text': 'post', 'createdAt': '2026-01-01T00:00:00.000Z'}, 73 + indexedAt: DateTime.utc(2026, 1, 1), 74 + ), 75 + ], 76 + cursor: 'next', 77 + hitsTotal: 42, 78 + ), 79 + ); 80 + } 81 + } 82 + 83 + class _FakeBluesky { 84 + _FakeBluesky(this.feed); 85 + 86 + final _FakeFeedService feed; 87 + } 88 + 89 + void main() { 90 + group('SearchRepository.searchPosts filter mapping', () { 91 + late _FakeFeedService feed; 92 + late SearchRepository repository; 93 + 94 + setUp(() { 95 + feed = _FakeFeedService(); 96 + repository = SearchRepository(bluesky: _FakeBluesky(feed)); 97 + }); 98 + 99 + test('maps all filters and sort to SDK call', () async { 100 + final since = DateTime.utc(2026, 1, 1, 5); 101 + final until = DateTime.utc(2026, 1, 2, 6); 102 + final result = await repository.searchPosts( 103 + query: ' flutter ', 104 + sort: 'latest', 105 + filters: PostSearchFilters( 106 + since: since, 107 + until: until, 108 + mentions: ' did:plc:mentions ', 109 + author: ' did:plc:author ', 110 + lang: ' en ', 111 + domain: ' example.com ', 112 + url: ' https://example.com ', 113 + tags: const [' #dart ', '#flutter', '#Dart'], 114 + ), 115 + cursor: 'cursor-1', 116 + limit: 120, 117 + ); 118 + 119 + expect(result.posts, hasLength(1)); 120 + expect(result.cursor, 'next'); 121 + expect(result.hitsTotal, 42); 122 + 123 + expect(feed.lastQ, 'flutter'); 124 + expect(feed.lastSort?.toJson(), KnownFeedSearchPostsSort.latest.value); 125 + expect(feed.lastSince, since.toIso8601String()); 126 + expect(feed.lastUntil, until.toIso8601String()); 127 + expect(feed.lastMentions, 'did:plc:mentions'); 128 + expect(feed.lastAuthor, 'did:plc:author'); 129 + expect(feed.lastLang, 'en'); 130 + expect(feed.lastDomain, 'example.com'); 131 + expect(feed.lastUrl, 'https://example.com'); 132 + expect(feed.lastTags, ['dart', 'flutter']); 133 + expect(feed.lastCursor, 'cursor-1'); 134 + expect(feed.lastLimit, 100); 135 + }); 136 + 137 + test('uses wildcard query for filter-only request', () async { 138 + await repository.searchPosts( 139 + query: ' ', 140 + filters: const PostSearchFilters(author: 'did:plc:author'), 141 + ); 142 + 143 + expect(feed.lastQ, '*'); 144 + expect(feed.lastAuthor, 'did:plc:author'); 145 + }); 146 + 147 + test('throws validation exception when query and filters are empty', () { 148 + expect( 149 + () => repository.searchPosts(query: ' ', filters: const PostSearchFilters()), 150 + throwsA(isA<PostSearchValidationException>()), 151 + ); 152 + }); 153 + }); 154 + }
+118
test/features/search/presentation/profile_post_search_screen_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_feed_defs.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:flutter_test/flutter_test.dart'; 7 + import 'package:lazurite/core/database/app_database.dart'; 8 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 9 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 10 + import 'package:lazurite/features/search/data/search_repository.dart'; 11 + import 'package:lazurite/features/search/presentation/search_screen.dart'; 12 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 13 + import 'package:mocktail/mocktail.dart'; 14 + 15 + class MockSearchRepository extends Mock implements SearchRepository {} 16 + 17 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 18 + 19 + class MockAppDatabase extends Mock implements AppDatabase {} 20 + 21 + void main() { 22 + late MockSearchRepository searchRepository; 23 + late MockTypeaheadRepository typeaheadRepository; 24 + late MockAppDatabase database; 25 + 26 + setUpAll(() { 27 + registerFallbackValue(const PostSearchFilters()); 28 + }); 29 + 30 + setUp(() { 31 + searchRepository = MockSearchRepository(); 32 + typeaheadRepository = MockTypeaheadRepository(); 33 + database = MockAppDatabase(); 34 + 35 + when( 36 + () => searchRepository.searchPosts( 37 + query: any(named: 'query'), 38 + sort: any(named: 'sort'), 39 + filters: any(named: 'filters'), 40 + cursor: any(named: 'cursor'), 41 + limit: any(named: 'limit'), 42 + ), 43 + ).thenAnswer( 44 + (_) async => SearchPostsResult( 45 + posts: [ 46 + PostView( 47 + uri: AtUri.parse('at://did:plc:test/app.bsky.feed.post/1'), 48 + cid: 'cid1', 49 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 50 + record: const {r'$type': 'app.bsky.feed.post', 'text': 'hello', 'createdAt': '2026-01-01T00:00:00.000Z'}, 51 + indexedAt: DateTime.utc(2026, 1, 1), 52 + ), 53 + ], 54 + ), 55 + ); 56 + }); 57 + 58 + testWidgets('uses fixed author in scoped mode and hides jump-to-profile action', (tester) async { 59 + await tester.pumpWidget( 60 + MaterialApp( 61 + home: BlocProvider( 62 + create: (_) => SearchBloc( 63 + searchRepository: searchRepository, 64 + typeaheadRepository: typeaheadRepository, 65 + database: database, 66 + accountDid: 'did:plc:viewer', 67 + config: const SearchBlocConfig.profileScoped(fixedPostAuthor: 'did:plc:fixed-author'), 68 + ), 69 + child: const SearchScreen( 70 + postsOnlyMode: true, 71 + fixedPostAuthor: 'did:plc:fixed-author', 72 + showBackButton: true, 73 + title: 'Search @fixed', 74 + showJumpToProfileAction: false, 75 + ), 76 + ), 77 + ), 78 + ); 79 + await tester.pumpAndSettle(); 80 + 81 + expect(find.text('Jump to profile'), findsNothing); 82 + 83 + verify( 84 + () => searchRepository.searchPosts( 85 + query: '', 86 + sort: 'latest', 87 + filters: any(named: 'filters'), 88 + cursor: any(named: 'cursor'), 89 + limit: any(named: 'limit'), 90 + ), 91 + ).called(1); 92 + 93 + await tester.tap(find.text('Filters')); 94 + await tester.pumpAndSettle(); 95 + 96 + expect(find.text('Author (fixed)'), findsOneWidget); 97 + expect(find.text('did:plc:fixed-author'), findsOneWidget); 98 + 99 + await tester.enterText(find.widgetWithText(TextField, 'Domain'), 'example.com'); 100 + await tester.ensureVisible(find.text('Apply')); 101 + await tester.tap(find.text('Apply')); 102 + await tester.pumpAndSettle(); 103 + 104 + final captured = verify( 105 + () => searchRepository.searchPosts( 106 + query: any(named: 'query'), 107 + sort: any(named: 'sort'), 108 + filters: captureAny(named: 'filters'), 109 + cursor: any(named: 'cursor'), 110 + limit: any(named: 'limit'), 111 + ), 112 + ).captured; 113 + 114 + final PostSearchFilters filters = captured.last as PostSearchFilters; 115 + expect(filters.author, 'did:plc:fixed-author'); 116 + expect(filters.domain, 'example.com'); 117 + }); 118 + }
+33 -2
test/features/search/presentation/search_screen_test.dart
··· 10 10 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 11 11 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 12 12 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 13 + import 'package:lazurite/features/search/data/post_search_filters.dart'; 13 14 import 'package:lazurite/features/search/data/search_repository.dart'; 14 15 import 'package:lazurite/features/search/presentation/search_screen.dart'; 15 16 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; ··· 30 31 void main() { 31 32 setUpAll(() { 32 33 registerFallbackValue(const SavedFeedType.knownValue(data: KnownSavedFeedType.feed)); 34 + registerFallbackValue(const PostSearchFilters()); 33 35 }); 34 36 35 37 group('SearchScreen', () { ··· 69 71 () => mockSearchRepository.searchPosts( 70 72 query: any(named: 'query'), 71 73 sort: any(named: 'sort'), 74 + filters: any(named: 'filters'), 72 75 cursor: any(named: 'cursor'), 73 76 limit: any(named: 'limit'), 74 77 ), ··· 171 174 expect(find.text('Posts'), findsOneWidget); 172 175 expect(find.text('People'), findsOneWidget); 173 176 expect(find.text('Feeds'), findsOneWidget); 174 - expect(find.text('Top'), findsNothing); 175 - expect(find.text('Latest'), findsNothing); 177 + expect(find.text('Top'), findsOneWidget); 178 + expect(find.text('Latest'), findsOneWidget); 176 179 }); 177 180 178 181 testWidgets('tabs are horizontally scrollable and starter packs label stays single-line', (tester) async { ··· 249 252 () => mockSearchRepository.searchPosts( 250 253 query: any(named: 'query'), 251 254 sort: any(named: 'sort'), 255 + filters: any(named: 'filters'), 252 256 cursor: any(named: 'cursor'), 253 257 limit: any(named: 'limit'), 254 258 ), ··· 309 313 310 314 expect(find.text('Top'), findsOneWidget); 311 315 expect(find.text('Latest'), findsOneWidget); 316 + }); 317 + 318 + testWidgets('post filters sheet applies filters and triggers filtered search', (tester) async { 319 + await tester.pumpWidget(buildSubject()); 320 + await tester.pumpAndSettle(); 321 + 322 + await tester.tap(find.text('Filters')); 323 + await tester.pumpAndSettle(); 324 + 325 + await tester.enterText(find.widgetWithText(TextField, 'Domain'), 'example.com'); 326 + await tester.ensureVisible(find.text('Apply')); 327 + await tester.tap(find.text('Apply')); 328 + await tester.pumpAndSettle(); 329 + 330 + final captured = verify( 331 + () => mockSearchRepository.searchPosts( 332 + query: any(named: 'query'), 333 + sort: any(named: 'sort'), 334 + filters: captureAny(named: 'filters'), 335 + cursor: any(named: 'cursor'), 336 + limit: any(named: 'limit'), 337 + ), 338 + ).captured; 339 + 340 + expect(captured, isNotEmpty); 341 + final PostSearchFilters filters = captured.last as PostSearchFilters; 342 + expect(filters.domain, 'example.com'); 312 343 }); 313 344 314 345 testWidgets('shows search history when available', (tester) async {
+30
test/features/search/presentation/widgets/search_result_states_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 4 + import 'package:lazurite/features/search/presentation/widgets/search_result_states.dart'; 5 + 6 + void main() { 7 + testWidgets('SearchEmptyState renders tab-aware empty copy', (tester) async { 8 + await tester.pumpWidget( 9 + const MaterialApp( 10 + home: Scaffold(body: SearchEmptyState(tab: SearchTab.posts)), 11 + ), 12 + ); 13 + 14 + expect(find.text('Search posts'), findsOneWidget); 15 + expect(find.textContaining('Find conversations and keywords across posts'), findsOneWidget); 16 + }); 17 + 18 + testWidgets('SearchNoResultsState includes query when provided', (tester) async { 19 + await tester.pumpWidget( 20 + const MaterialApp( 21 + home: Scaffold( 22 + body: SearchNoResultsState(tab: SearchTab.actors, query: 'river'), 23 + ), 24 + ), 25 + ); 26 + 27 + expect(find.text('No people found'), findsOneWidget); 28 + expect(find.textContaining('for "river"'), findsOneWidget); 29 + }); 30 + }