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

+535 -220
+180 -1
lib/core/database/app_database.dart
··· 5 5 6 6 part 'app_database.g.dart'; 7 7 8 + class KeywordPostMatch { 9 + const KeywordPostMatch({required this.postUri, required this.source, required this.rank}); 10 + 11 + final String postUri; 12 + final String source; 13 + final double rank; 14 + } 15 + 8 16 @DriftDatabase( 9 17 tables: [ 10 18 Accounts, ··· 29 37 static const activeAccountDidSettingKey = 'active_account_did'; 30 38 31 39 @override 32 - int get schemaVersion => 21; 40 + int get schemaVersion => 22; 33 41 34 42 @override 35 43 MigrationStrategy get migration => MigrationStrategy( 36 44 onCreate: (migrator) async { 37 45 await migrator.createAll(); 46 + await _createPostSearchFtsSchema(); 47 + await _rebuildPostSearchFts(); 38 48 await customStatement( 39 49 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' 40 50 'ON notification_deliveries(notification_uri)', ··· 154 164 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' 155 165 'ON notification_deliveries(notification_uri)', 156 166 ); 167 + } 168 + if (from < 22) { 169 + await _createPostSearchFtsSchema(); 170 + await _rebuildPostSearchFts(); 157 171 } 158 172 }, 159 173 ); ··· 594 608 595 609 Future<int> deleteAllLikedPosts(String accountDid) => 596 610 (delete(likedPosts)..where((l) => l.accountDid.equals(accountDid))).go(); 611 + 612 + Future<List<KeywordPostMatch>> searchPostsByKeyword({ 613 + required String accountDid, 614 + required String query, 615 + String? source, 616 + int limit = 20, 617 + }) async { 618 + final ftsQuery = _buildFtsQuery(query); 619 + if (ftsQuery == null || limit <= 0) { 620 + return const []; 621 + } 622 + 623 + final sourceFilter = source == 'saved' || source == 'liked' ? source : null; 624 + final rows = await customSelect( 625 + ''' 626 + SELECT post_uri, source, bm25(post_search_fts, 8.0, 1.0) AS rank 627 + FROM post_search_fts 628 + WHERE account_did = ? 629 + ${sourceFilter == null ? '' : 'AND source = ?'} 630 + AND post_search_fts MATCH ? 631 + ORDER BY rank ASC 632 + LIMIT ? 633 + ''', 634 + variables: [ 635 + Variable(accountDid), 636 + if (sourceFilter != null) Variable(sourceFilter), 637 + Variable(ftsQuery), 638 + Variable(limit), 639 + ], 640 + ).get(); 641 + return _mapKeywordPostMatches(rows); 642 + } 643 + 644 + List<KeywordPostMatch> _mapKeywordPostMatches(List<QueryRow> rows) { 645 + return rows 646 + .map( 647 + (row) => KeywordPostMatch( 648 + postUri: row.read<String>('post_uri'), 649 + source: row.read<String>('source'), 650 + rank: row.read<double>('rank'), 651 + ), 652 + ) 653 + .toList(growable: false); 654 + } 655 + 656 + Future<void> _createPostSearchFtsSchema() async { 657 + await customStatement(''' 658 + CREATE VIRTUAL TABLE IF NOT EXISTS post_search_fts USING fts5( 659 + account_did UNINDEXED, 660 + source UNINDEXED, 661 + post_uri UNINDEXED, 662 + handle, 663 + content, 664 + tokenize = 'unicode61' 665 + ) 666 + '''); 667 + 668 + await customStatement(''' 669 + CREATE TRIGGER IF NOT EXISTS saved_posts_ai 670 + AFTER INSERT ON saved_posts BEGIN 671 + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) 672 + VALUES ( 673 + new.account_did, 674 + 'saved', 675 + new.post_uri, 676 + coalesce(json_extract(new.post_json, '\$.author.handle'), ''), 677 + coalesce(json_extract(new.post_json, '\$.record.text'), '') 678 + ); 679 + END 680 + '''); 681 + 682 + await customStatement(''' 683 + CREATE TRIGGER IF NOT EXISTS saved_posts_au 684 + AFTER UPDATE ON saved_posts BEGIN 685 + DELETE FROM post_search_fts 686 + WHERE account_did = old.account_did AND source = 'saved' AND post_uri = old.post_uri; 687 + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) 688 + VALUES ( 689 + new.account_did, 690 + 'saved', 691 + new.post_uri, 692 + coalesce(json_extract(new.post_json, '\$.author.handle'), ''), 693 + coalesce(json_extract(new.post_json, '\$.record.text'), '') 694 + ); 695 + END 696 + '''); 697 + 698 + await customStatement(''' 699 + CREATE TRIGGER IF NOT EXISTS saved_posts_ad 700 + AFTER DELETE ON saved_posts BEGIN 701 + DELETE FROM post_search_fts 702 + WHERE account_did = old.account_did AND source = 'saved' AND post_uri = old.post_uri; 703 + END 704 + '''); 705 + 706 + await customStatement(''' 707 + CREATE TRIGGER IF NOT EXISTS liked_posts_ai 708 + AFTER INSERT ON liked_posts BEGIN 709 + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) 710 + VALUES ( 711 + new.account_did, 712 + 'liked', 713 + new.post_uri, 714 + coalesce(json_extract(new.post_json, '\$.post.author.handle'), coalesce(json_extract(new.post_json, '\$.author.handle'), '')), 715 + coalesce(json_extract(new.post_json, '\$.post.record.text'), coalesce(json_extract(new.post_json, '\$.record.text'), '')) 716 + ); 717 + END 718 + '''); 719 + 720 + await customStatement(''' 721 + CREATE TRIGGER IF NOT EXISTS liked_posts_au 722 + AFTER UPDATE ON liked_posts BEGIN 723 + DELETE FROM post_search_fts 724 + WHERE account_did = old.account_did AND source = 'liked' AND post_uri = old.post_uri; 725 + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) 726 + VALUES ( 727 + new.account_did, 728 + 'liked', 729 + new.post_uri, 730 + coalesce(json_extract(new.post_json, '\$.post.author.handle'), coalesce(json_extract(new.post_json, '\$.author.handle'), '')), 731 + coalesce(json_extract(new.post_json, '\$.post.record.text'), coalesce(json_extract(new.post_json, '\$.record.text'), '')) 732 + ); 733 + END 734 + '''); 735 + 736 + await customStatement(''' 737 + CREATE TRIGGER IF NOT EXISTS liked_posts_ad 738 + AFTER DELETE ON liked_posts BEGIN 739 + DELETE FROM post_search_fts 740 + WHERE account_did = old.account_did AND source = 'liked' AND post_uri = old.post_uri; 741 + END 742 + '''); 743 + } 744 + 745 + Future<void> _rebuildPostSearchFts() async { 746 + await customStatement('DELETE FROM post_search_fts'); 747 + await customStatement(''' 748 + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) 749 + SELECT 750 + account_did, 751 + 'saved', 752 + post_uri, 753 + coalesce(json_extract(post_json, '\$.author.handle'), ''), 754 + coalesce(json_extract(post_json, '\$.record.text'), '') 755 + FROM saved_posts 756 + '''); 757 + await customStatement(''' 758 + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) 759 + SELECT 760 + account_did, 761 + 'liked', 762 + post_uri, 763 + coalesce(json_extract(post_json, '\$.post.author.handle'), coalesce(json_extract(post_json, '\$.author.handle'), '')), 764 + coalesce(json_extract(post_json, '\$.post.record.text'), coalesce(json_extract(post_json, '\$.record.text'), '')) 765 + FROM liked_posts 766 + '''); 767 + } 768 + 769 + static String? _buildFtsQuery(String rawQuery) { 770 + final tokens = RegExp(r'[A-Za-z0-9_]+').allMatches(rawQuery.toLowerCase()).map((m) => m.group(0)!).toList(); 771 + if (tokens.isEmpty) { 772 + return null; 773 + } 774 + return tokens.map((token) => '$token*').join(' AND '); 775 + } 597 776 598 777 Future<bool> recordNotificationDelivery({ 599 778 required String accountDid,
+9 -19
lib/features/search/cubit/semantic_search_cubit.dart
··· 76 76 /// Queue a search for [query], debounced by [_debounceDuration]. 77 77 /// 78 78 /// An empty query clears results immediately without waiting for the debounce. 79 - /// Attempts to recover service availability before searching. 79 + /// The repository merges keyword and semantic results. 80 80 void search(String query) { 81 81 _debounce?.cancel(); 82 82 if (query.trim().isEmpty) { 83 83 emit(SemanticSearchState(scope: state.scope)); 84 84 return; 85 85 } 86 - _debounce = Timer(_debounceDuration, () => unawaited(_searchWithAvailability(query))); 86 + _debounce = Timer(_debounceDuration, () => unawaited(_doSearch(query))); 87 87 } 88 88 89 89 /// Change the search scope and immediately re-run the current query. ··· 92 92 emit(state.copyWith(scope: scope)); 93 93 if (state.query.trim().isNotEmpty) { 94 94 _debounce?.cancel(); 95 - if (await _ensureAvailable()) { 96 - await _doSearch(state.query); 97 - } else if (!isClosed) { 98 - emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Semantic model unavailable.')); 99 - } 95 + await _doSearch(state.query); 100 96 } 101 97 } 102 98 ··· 106 102 emit(SemanticSearchState(scope: state.scope)); 107 103 } 108 104 109 - Future<bool> _ensureAvailable() async { 110 - if (_embeddingService.isAvailable) return true; 111 - await _embeddingService.initialize(); 112 - return _embeddingService.isAvailable; 113 - } 114 - 115 - Future<void> _searchWithAvailability(String query) async { 116 - if (await _ensureAvailable()) { 117 - await _doSearch(query); 118 - } else if (!isClosed) { 119 - emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Semantic model unavailable.')); 120 - } 105 + Future<void> _warmSemanticModel() async { 106 + if (_embeddingService.isAvailable) return; 107 + try { 108 + await _embeddingService.initialize(); 109 + } catch (_) {} 121 110 } 122 111 123 112 Future<void> _doSearch(String query) async { 124 113 emit(state.copyWith(status: SemanticSearchStatus.searching, query: query)); 125 114 try { 115 + await _warmSemanticModel(); 126 116 final source = switch (state.scope) { 127 117 SearchScope.saved => 'saved', 128 118 SearchScope.liked => 'liked',
+82 -10
lib/features/search/data/semantic_search_repository.dart
··· 5 5 6 6 /// Performs on-device semantic (vector) search over a user's saved and liked posts. 7 7 /// 8 - /// Embeds the query using [EmbeddingService], runs an HNSW nearest-neighbour 9 - /// search via ObjectBox, then joins each result back to [AppDatabase] to 10 - /// hydrate the full post JSON for display. 8 + /// Runs keyword matching first (handle + content), then augments with 9 + /// semantic nearest-neighbour matches when embeddings are available. 11 10 class SemanticSearchRepository { 12 11 SemanticSearchRepository({ 13 12 required EmbeddingService embeddingService, ··· 34 33 String? source, 35 34 int maxResults = 20, 36 35 }) async { 37 - if (!_embeddingService.isAvailable) return const []; 38 - if (query.trim().isEmpty) return const []; 36 + final normalizedQuery = query.trim(); 37 + if (normalizedQuery.isEmpty || maxResults <= 0) return const []; 39 38 40 - final queryVector = await _embeddingService.embed(query); 39 + final keywordResults = await _keywordSearch(normalizedQuery, accountDid, source: source, maxResults: maxResults); 40 + if (!_embeddingService.isAvailable) { 41 + return keywordResults.take(maxResults).toList(growable: false); 42 + } 41 43 44 + try { 45 + final semanticResults = await _semanticSearch( 46 + normalizedQuery, 47 + accountDid, 48 + source: source, 49 + maxResults: maxResults, 50 + ); 51 + return _mergeResults(keywordResults, semanticResults, maxResults); 52 + } catch (_) { 53 + return keywordResults.take(maxResults).toList(growable: false); 54 + } 55 + } 56 + 57 + Future<List<SemanticSearchResult>> _keywordSearch( 58 + String query, 59 + String accountDid, { 60 + String? source, 61 + required int maxResults, 62 + }) async { 63 + final matches = await _database.searchPostsByKeyword( 64 + accountDid: accountDid, 65 + query: query, 66 + source: source, 67 + limit: maxResults, 68 + ); 69 + 70 + final results = <SemanticSearchResult>[]; 71 + for (var index = 0; index < matches.length; index++) { 72 + final match = matches[index]; 73 + final postJson = await _fetchPostJson(accountDid, match.postUri, match.source); 74 + if (postJson == null) { 75 + continue; 76 + } 77 + // FTS rank determines order; map position to a readable confidence range. 78 + final score = (90.0 - (index * 2.5)).clamp(55.0, 95.0).toDouble(); 79 + results.add(SemanticSearchResult(postUri: match.postUri, score: score, source: match.source, postJson: postJson)); 80 + } 81 + return results; 82 + } 83 + 84 + Future<List<SemanticSearchResult>> _semanticSearch( 85 + String query, 86 + String accountDid, { 87 + String? source, 88 + required int maxResults, 89 + }) async { 90 + final queryVector = await _embeddingService.embed(query); 42 91 final rawResults = _embeddingRepository.nearestNeighbors( 43 92 queryVector, 44 93 accountDid, ··· 50 99 for (final result in rawResults) { 51 100 final post = result.object; 52 101 final similarity = (1.0 - result.score).clamp(0.0, 1.0); 53 - final scorePercent = similarity * 100.0; 54 - 102 + final scorePercent = (similarity * 99.0) + 1.0; 55 103 final postJson = await _fetchPostJson(accountDid, post.postUri, post.source); 56 104 if (postJson == null) continue; 57 - 58 105 results.add( 59 106 SemanticSearchResult(postUri: post.postUri, score: scorePercent, source: post.source, postJson: postJson), 60 107 ); 61 108 } 62 - 63 109 return results; 110 + } 111 + 112 + List<SemanticSearchResult> _mergeResults( 113 + List<SemanticSearchResult> keywordResults, 114 + List<SemanticSearchResult> semanticResults, 115 + int maxResults, 116 + ) { 117 + final merged = <SemanticSearchResult>[]; 118 + final seen = <String>{}; 119 + 120 + void addUnique(SemanticSearchResult result) { 121 + final key = '${result.source}|${result.postUri}'; 122 + if (!seen.add(key)) return; 123 + merged.add(result); 124 + } 125 + 126 + for (final result in keywordResults) { 127 + addUnique(result); 128 + if (merged.length >= maxResults) return merged; 129 + } 130 + for (final result in semanticResults) { 131 + addUnique(result); 132 + if (merged.length >= maxResults) return merged; 133 + } 134 + 135 + return merged; 64 136 } 65 137 66 138 Future<String?> _fetchPostJson(String accountDid, String postUri, String source) async {
+203 -168
lib/features/search/presentation/semantic_search_tab.dart
··· 29 29 30 30 class _SemanticSearchTabState extends State<SemanticSearchTab> { 31 31 final TextEditingController _controller = TextEditingController(); 32 + final FocusNode _searchFocusNode = FocusNode(); 32 33 33 34 @override 34 35 void initState() { ··· 46 47 @override 47 48 void dispose() { 48 49 _controller.dispose(); 50 + _searchFocusNode.dispose(); 49 51 super.dispose(); 50 52 } 51 53 52 54 @override 53 55 Widget build(BuildContext context) { 54 - return BlocBuilder<SemanticSearchCubit, SemanticSearchState>( 55 - builder: (context, state) { 56 - return BlocBuilder<SettingsCubit, SettingsState>( 57 - builder: (context, _) { 58 - return Column( 59 - children: [ 60 - BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 61 - builder: (context, indexState) { 62 - return _IndexControls(indexState: indexState); 63 - }, 64 - ), 65 - _SearchBar( 66 - controller: _controller, 67 - onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 68 - onClear: () { 69 - _controller.clear(); 70 - context.read<SemanticSearchCubit>().clearResults(); 71 - }, 72 - ), 73 - _ScopeChips( 74 - selected: state.scope, 75 - onSelected: (scope) async { 76 - await context.read<SemanticSearchCubit>().setScope(scope); 77 - if (context.mounted) { 78 - unawaited(context.read<SettingsCubit>().setSearchScope(scope)); 79 - } 80 - }, 81 - ), 82 - const Divider(height: 1), 83 - Expanded(child: _ResultsView(state: state)), 84 - ], 85 - ); 86 - }, 87 - ); 56 + return BlocListener<SettingsCubit, SettingsState>( 57 + listenWhen: (previous, current) => 58 + previous.searchScope != current.searchScope || 59 + previous.semanticSearchMaxResults != current.semanticSearchMaxResults, 60 + listener: (context, settingsState) { 61 + context.read<SemanticSearchCubit>().setMaxResults(settingsState.semanticSearchMaxResults); 62 + unawaited(context.read<SemanticSearchCubit>().setScope(settingsState.searchScope)); 88 63 }, 64 + child: Column( 65 + children: [ 66 + BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 67 + builder: (context, indexState) { 68 + return _SearchInputRow( 69 + controller: _controller, 70 + focusNode: _searchFocusNode, 71 + onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 72 + onClear: () { 73 + _controller.clear(); 74 + context.read<SemanticSearchCubit>().clearResults(); 75 + }, 76 + indexState: indexState, 77 + ); 78 + }, 79 + ), 80 + BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 81 + builder: (context, indexState) { 82 + return BlocSelector<SemanticSearchCubit, SemanticSearchState, SearchScope>( 83 + selector: (state) => state.scope, 84 + builder: (context, selectedScope) { 85 + return _ScopeRow( 86 + selected: selectedScope, 87 + indexState: indexState, 88 + onSelected: (scope) async { 89 + await context.read<SemanticSearchCubit>().setScope(scope); 90 + if (context.mounted) { 91 + unawaited(context.read<SettingsCubit>().setSearchScope(scope)); 92 + } 93 + }, 94 + ); 95 + }, 96 + ); 97 + }, 98 + ), 99 + const Divider(height: 1), 100 + Expanded( 101 + child: BlocBuilder<SemanticSearchCubit, SemanticSearchState>( 102 + buildWhen: (previous, current) => 103 + previous.status != current.status || 104 + previous.results != current.results || 105 + previous.errorMessage != current.errorMessage, 106 + builder: (context, state) => _ResultsView(state: state), 107 + ), 108 + ), 109 + ], 110 + ), 89 111 ); 90 112 } 91 113 } 92 114 93 - class _IndexControls extends StatelessWidget { 94 - const _IndexControls({required this.indexState}); 115 + class _SearchInputRow extends StatelessWidget { 116 + const _SearchInputRow({ 117 + required this.controller, 118 + required this.focusNode, 119 + required this.onChanged, 120 + required this.onClear, 121 + required this.indexState, 122 + }); 95 123 124 + final TextEditingController controller; 125 + final FocusNode focusNode; 126 + final ValueChanged<String> onChanged; 127 + final VoidCallback onClear; 96 128 final SemanticIndexState indexState; 129 + 130 + @override 131 + Widget build(BuildContext context) { 132 + return Padding( 133 + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), 134 + child: Row( 135 + crossAxisAlignment: CrossAxisAlignment.start, 136 + children: [ 137 + Expanded( 138 + child: _SearchBar(controller: controller, focusNode: focusNode, onChanged: onChanged, onClear: onClear), 139 + ), 140 + const SizedBox(width: 8), 141 + PopupMenuButton<_IndexMenuAction>( 142 + tooltip: 'Search index actions', 143 + icon: const Icon(Icons.more_vert), 144 + onSelected: (action) => unawaited(_onMenuSelected(context, action)), 145 + itemBuilder: (context) => [ 146 + const PopupMenuItem<_IndexMenuAction>( 147 + value: _IndexMenuAction.semanticSettings, 148 + child: Text('Semantic settings'), 149 + ), 150 + const PopupMenuItem<_IndexMenuAction>( 151 + value: _IndexMenuAction.refreshCount, 152 + child: Text('Refresh indexed count'), 153 + ), 154 + PopupMenuItem<_IndexMenuAction>( 155 + value: _IndexMenuAction.reindex, 156 + enabled: !indexState.isBackfilling, 157 + child: const Text('Re-index posts'), 158 + ), 159 + ], 160 + ), 161 + ], 162 + ), 163 + ); 164 + } 97 165 98 166 Future<void> _onMenuSelected(BuildContext context, _IndexMenuAction action) async { 99 167 final cubit = context.read<SemanticIndexCubit>(); ··· 114 182 break; 115 183 } 116 184 } 185 + } 186 + 187 + class _ScopeRow extends StatelessWidget { 188 + const _ScopeRow({required this.selected, required this.onSelected, required this.indexState}); 189 + 190 + final SearchScope selected; 191 + final ValueChanged<SearchScope> onSelected; 192 + final SemanticIndexState indexState; 117 193 118 194 @override 119 195 Widget build(BuildContext context) { 120 196 final scheme = context.colorScheme; 121 - final completed = indexState.backfillCompleted ?? 0; 122 - final total = indexState.backfillTotal ?? 0; 123 - final progress = total > 0 ? completed / total : 0.0; 124 - return Container( 125 - padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), 126 - color: scheme.surface, 127 - child: Column( 197 + final scopeOrder = [SearchScope.both, SearchScope.saved, SearchScope.liked]; 198 + 199 + return Padding( 200 + padding: const EdgeInsets.fromLTRB(12, 2, 12, 8), 201 + child: Row( 128 202 children: [ 129 - Row( 130 - children: [ 131 - Expanded( 132 - child: Text( 133 - indexState.isBackfilling 134 - ? 'Indexing: ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0} posts...' 135 - : '${indexState.indexedCount} posts indexed', 136 - style: context.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), 137 - ), 138 - ), 139 - PopupMenuButton<_IndexMenuAction>( 140 - tooltip: 'Search index actions', 141 - icon: const Icon(Icons.more_vert), 142 - onSelected: (action) => unawaited(_onMenuSelected(context, action)), 143 - itemBuilder: (context) => [ 144 - const PopupMenuItem<_IndexMenuAction>( 145 - value: _IndexMenuAction.semanticSettings, 146 - child: Text('Semantic settings'), 147 - ), 148 - const PopupMenuItem<_IndexMenuAction>( 149 - value: _IndexMenuAction.refreshCount, 150 - child: Text('Refresh indexed count'), 151 - ), 152 - PopupMenuItem<_IndexMenuAction>( 153 - value: _IndexMenuAction.reindex, 154 - enabled: !indexState.isBackfilling, 155 - child: const Text('Re-index posts'), 156 - ), 157 - ], 158 - ), 159 - ], 160 - ), 161 - if (indexState.isBackfilling) ...[ 162 - const SizedBox(height: 4), 163 - LinearProgressIndicator(value: progress > 0 ? progress : null), 164 - ], 165 - if (indexState.status == SemanticIndexStatus.error && indexState.errorMessage != null) ...[ 166 - const SizedBox(height: 6), 167 - Align( 168 - alignment: Alignment.centerLeft, 169 - child: Text(indexState.errorMessage!, style: context.textTheme.bodySmall?.copyWith(color: scheme.error)), 203 + for (var index = 0; index < scopeOrder.length; index++) ...[ 204 + _ScopeChip( 205 + label: _ScopeChips.labels[scopeOrder[index]]!, 206 + isSelected: selected == scopeOrder[index], 207 + onTap: () => onSelected(scopeOrder[index]), 170 208 ), 209 + if (index < scopeOrder.length - 1) const SizedBox(width: 8), 171 210 ], 211 + const Spacer(), 212 + Text( 213 + indexState.isBackfilling 214 + ? 'Indexing ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0}' 215 + : '${indexState.indexedCount} indexed', 216 + style: context.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), 217 + ), 172 218 ], 173 219 ), 174 220 ); 175 221 } 176 222 } 177 223 224 + class _SearchBar extends StatelessWidget { 225 + const _SearchBar({required this.controller, required this.focusNode, required this.onChanged, required this.onClear}); 226 + 227 + final TextEditingController controller; 228 + final FocusNode focusNode; 229 + final ValueChanged<String> onChanged; 230 + final VoidCallback onClear; 231 + 232 + @override 233 + Widget build(BuildContext context) { 234 + final scheme = context.colorScheme; 235 + return TextField( 236 + focusNode: focusNode, 237 + controller: controller, 238 + onChanged: onChanged, 239 + textInputAction: TextInputAction.search, 240 + decoration: InputDecoration( 241 + hintText: 'Search saved and liked posts...', 242 + prefixIcon: const Icon(Icons.search, size: 20), 243 + suffixIcon: controller.text.isNotEmpty 244 + ? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: onClear, tooltip: 'Clear') 245 + : null, 246 + border: OutlineInputBorder( 247 + borderRadius: BorderRadius.circular(99), 248 + borderSide: BorderSide(color: scheme.outlineVariant), 249 + ), 250 + enabledBorder: OutlineInputBorder( 251 + borderRadius: BorderRadius.circular(99), 252 + borderSide: BorderSide(color: scheme.outlineVariant), 253 + ), 254 + focusedBorder: OutlineInputBorder( 255 + borderRadius: BorderRadius.circular(99), 256 + borderSide: BorderSide(color: scheme.primary), 257 + ), 258 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 259 + isDense: true, 260 + ), 261 + ); 262 + } 263 + } 264 + 265 + class _ScopeChips { 266 + static const labels = {SearchScope.both: 'Both', SearchScope.saved: 'Saved', SearchScope.liked: 'Liked'}; 267 + } 268 + 178 269 enum _IndexMenuAction { semanticSettings, refreshCount, reindex } 179 270 180 271 class _SemanticSettingsSheet extends StatelessWidget { ··· 243 334 ), 244 335 ); 245 336 }, 246 - ), 247 - ); 248 - } 249 - } 250 - 251 - class _SearchBar extends StatelessWidget { 252 - const _SearchBar({required this.controller, required this.onChanged, required this.onClear}); 253 - 254 - final TextEditingController controller; 255 - final ValueChanged<String> onChanged; 256 - final VoidCallback onClear; 257 - 258 - @override 259 - Widget build(BuildContext context) { 260 - final scheme = context.colorScheme; 261 - return Padding( 262 - padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), 263 - child: TextField( 264 - controller: controller, 265 - onChanged: onChanged, 266 - textInputAction: TextInputAction.search, 267 - decoration: InputDecoration( 268 - hintText: 'Search your saved posts...', 269 - prefixIcon: const Icon(Icons.search, size: 20), 270 - suffixIcon: controller.text.isNotEmpty 271 - ? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: onClear, tooltip: 'Clear') 272 - : null, 273 - border: OutlineInputBorder( 274 - borderRadius: BorderRadius.circular(99), 275 - borderSide: BorderSide(color: scheme.outlineVariant), 276 - ), 277 - enabledBorder: OutlineInputBorder( 278 - borderRadius: BorderRadius.circular(99), 279 - borderSide: BorderSide(color: scheme.outlineVariant), 280 - ), 281 - focusedBorder: OutlineInputBorder( 282 - borderRadius: BorderRadius.circular(99), 283 - borderSide: BorderSide(color: scheme.primary), 284 - ), 285 - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 286 - isDense: true, 287 - ), 288 - ), 289 - ); 290 - } 291 - } 292 - 293 - class _ScopeChips extends StatelessWidget { 294 - const _ScopeChips({required this.selected, required this.onSelected}); 295 - 296 - final SearchScope selected; 297 - final ValueChanged<SearchScope> onSelected; 298 - 299 - static const _labels = {SearchScope.both: 'Both', SearchScope.saved: 'Saved', SearchScope.liked: 'Liked'}; 300 - 301 - @override 302 - Widget build(BuildContext context) { 303 - return Padding( 304 - padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), 305 - child: Row( 306 - children: [ 307 - for (final scope in SearchScope.values) ...[ 308 - _ScopeChip(label: _labels[scope]!, isSelected: selected == scope, onTap: () => onSelected(scope)), 309 - if (scope != SearchScope.liked) const SizedBox(width: 8), 310 - ], 311 - ], 312 337 ), 313 338 ); 314 339 } ··· 495 520 @override 496 521 Widget build(BuildContext context) { 497 522 final scheme = context.colorScheme; 498 - return Center( 499 - child: SingleChildScrollView( 500 - padding: const EdgeInsets.all(32), 501 - child: Column( 502 - mainAxisSize: MainAxisSize.min, 503 - mainAxisAlignment: MainAxisAlignment.center, 504 - children: [ 505 - Icon(Icons.travel_explore_outlined, size: 64, color: scheme.outline), 506 - const SizedBox(height: 16), 507 - Text('Search by meaning', style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant)), 508 - const SizedBox(height: 8), 509 - Text( 510 - 'Search your saved and liked posts by meaning, not just keywords', 511 - textAlign: TextAlign.center, 512 - style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 523 + return LayoutBuilder( 524 + builder: (context, constraints) { 525 + return SingleChildScrollView( 526 + padding: const EdgeInsets.all(32), 527 + child: ConstrainedBox( 528 + constraints: BoxConstraints(minHeight: constraints.maxHeight - 64), 529 + child: Center( 530 + child: Column( 531 + mainAxisSize: MainAxisSize.min, 532 + mainAxisAlignment: MainAxisAlignment.center, 533 + children: [ 534 + Icon(Icons.travel_explore_outlined, size: 64, color: scheme.outline), 535 + const SizedBox(height: 16), 536 + Text( 537 + 'Search your saved & liked posts', 538 + style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 539 + ), 540 + const SizedBox(height: 8), 541 + Text( 542 + 'Find posts by handle, text, and semantic similarity', 543 + textAlign: TextAlign.center, 544 + style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 545 + ), 546 + ], 547 + ), 513 548 ), 514 - ], 515 - ), 516 - ), 549 + ), 550 + ); 551 + }, 517 552 ); 518 553 } 519 554 }
+21 -15
test/features/search/cubit/semantic_search_cubit_test.dart
··· 235 235 ); 236 236 237 237 blocTest<SemanticSearchCubit, SemanticSearchState>( 238 - 'emits error when service is not available', 239 - build: () => 240 - SemanticSearchCubit( 241 - repository: mockRepo, 242 - embeddingService: _unavailableService(), 243 - accountDid: _accountDid, 244 - debounceDuration: Duration.zero, 238 + 'still searches when service is not available', 239 + build: () => SemanticSearchCubit( 240 + repository: mockRepo, 241 + embeddingService: _unavailableService(), 242 + accountDid: _accountDid, 243 + debounceDuration: Duration.zero, 244 + ), 245 + setUp: () { 246 + when( 247 + () => mockRepo.search( 248 + any(), 249 + any(), 250 + source: any(named: 'source'), 251 + maxResults: any(named: 'maxResults'), 245 252 ), 253 + ).thenAnswer((_) async => const []); 254 + }, 246 255 act: (cubit) => cubit.search('flutter'), 247 256 expect: () => [ 248 - predicate<SemanticSearchState>( 249 - (s) => 250 - s.status == SemanticSearchStatus.error && 251 - (s.errorMessage?.contains('Semantic model unavailable') ?? false), 252 - ), 257 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.searching), 258 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.loaded && s.results.isEmpty), 253 259 ], 254 - verify: (cubit) { 255 - verifyNever(() => mockRepo.search(any(), any())); 256 - }, 260 + verify: (_) => verify( 261 + () => mockRepo.search('flutter', _accountDid, source: null, maxResults: any(named: 'maxResults')), 262 + ).called(1), 257 263 ); 258 264 }); 259 265
+36 -3
test/features/search/data/semantic_search_repository_test.dart
··· 133 133 134 134 group('SemanticSearchRepository', () { 135 135 group('search', () { 136 - test('returns empty list when EmbeddingService is unavailable', () async { 136 + test('returns keyword results when EmbeddingService is unavailable', () async { 137 137 await insertSavedPost('at://did/post/1', 'did:plc:user'); 138 138 139 139 final repo = makeRepo(service: _unavailableService()); 140 - final results = await repo.search('hello', 'did:plc:user'); 140 + final results = await repo.search('post text', 'did:plc:user'); 141 141 142 - expect(results, isEmpty); 142 + expect(results, hasLength(1)); 143 + expect(results.first.postUri, equals('at://did/post/1')); 144 + expect(results.first.source, equals('saved')); 143 145 }); 144 146 145 147 test('returns empty list when query is empty', () async { ··· 201 203 expect((decoded['post'] as Map<String, dynamic>)['uri'], equals('at://did/post/2')); 202 204 }); 203 205 206 + test('matches saved posts by author handle keyword', () async { 207 + await insertSavedPost('at://did/post/handle', 'did:plc:user', text: 'no handle text here'); 208 + 209 + final repo = makeRepo(service: _unavailableService()); 210 + final results = await repo.search('author.bsky.social', 'did:plc:user'); 211 + 212 + expect(results, hasLength(1)); 213 + expect(results.first.postUri, equals('at://did/post/handle')); 214 + expect(results.first.source, equals('saved')); 215 + }); 216 + 217 + test('matches liked posts by content keyword', () async { 218 + await insertLikedPost('at://did/post/liked-keyword', 'did:plc:user', text: 'Dart keyword search works'); 219 + 220 + final repo = makeRepo(service: _unavailableService()); 221 + final results = await repo.search('keyword search', 'did:plc:user'); 222 + 223 + expect(results, hasLength(1)); 224 + expect(results.first.postUri, equals('at://did/post/liked-keyword')); 225 + expect(results.first.source, equals('liked')); 226 + }); 227 + 204 228 test('score is in the range [0, 100]', () async { 205 229 await insertSavedPost('at://did/post/1', 'did:plc:user'); 206 230 ··· 245 269 expect(results.length, equals(2)); 246 270 final sources = results.map((r) => r.source).toSet(); 247 271 expect(sources, containsAll(['saved', 'liked'])); 272 + }); 273 + 274 + test('deduplicates combined keyword and semantic hits for the same source and post', () async { 275 + await insertSavedPost('at://did/post/dupe', 'did:plc:user', text: 'keyword and semantic'); 276 + 277 + final repo = makeRepo(); 278 + final results = await repo.search('keyword', 'did:plc:user'); 279 + 280 + expect(results.where((result) => result.postUri == 'at://did/post/dupe' && result.source == 'saved').length, 1); 248 281 }); 249 282 250 283 test('filters to saved posts when source is "saved"', () async {
+4 -4
test/features/search/presentation/semantic_search_tab_test.dart
··· 108 108 group('SemanticSearchTab', () { 109 109 testWidgets('shows empty query state when no query entered', (tester) async { 110 110 await tester.pumpWidget(buildSubject()); 111 - expect(find.text('Search by meaning'), findsOneWidget); 112 - expect(find.text('Search your saved and liked posts by meaning, not just keywords'), findsOneWidget); 111 + expect(find.text('Search your saved & liked posts'), findsOneWidget); 112 + expect(find.text('Find posts by handle, text, and semantic similarity'), findsOneWidget); 113 113 }); 114 114 115 115 testWidgets('shows loading indicator while searching', (tester) async { ··· 222 222 ), 223 223 ); 224 224 await tester.pumpWidget(buildSubject()); 225 - expect(find.text('Indexing: 42/100 posts...'), findsOneWidget); 225 + expect(find.text('Indexing 42/100'), findsOneWidget); 226 226 }); 227 227 228 228 testWidgets('shows indexed count header when idle', (tester) async { 229 229 await tester.pumpWidget(buildSubject()); 230 - expect(find.text('0 posts indexed'), findsOneWidget); 230 + expect(find.text('0 indexed'), findsOneWidget); 231 231 }); 232 232 233 233 testWidgets('kebab menu refresh action triggers loadCount', (tester) async {