diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index ddac179..58c093d 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -5,6 +5,14 @@ import 'package:path_provider/path_provider.dart'; part 'app_database.g.dart'; +class KeywordPostMatch { + const KeywordPostMatch({required this.postUri, required this.source, required this.rank}); + + final String postUri; + final String source; + final double rank; +} + @DriftDatabase( tables: [ Accounts, @@ -29,12 +37,14 @@ class AppDatabase extends _$AppDatabase { static const activeAccountDidSettingKey = 'active_account_did'; @override - int get schemaVersion => 21; + int get schemaVersion => 22; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); + await _createPostSearchFtsSchema(); + await _rebuildPostSearchFts(); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_notification_deliveries_notification_uri ' 'ON notification_deliveries(notification_uri)', @@ -155,6 +165,10 @@ class AppDatabase extends _$AppDatabase { 'ON notification_deliveries(notification_uri)', ); } + if (from < 22) { + await _createPostSearchFtsSchema(); + await _rebuildPostSearchFts(); + } }, ); @@ -595,6 +609,171 @@ class AppDatabase extends _$AppDatabase { Future deleteAllLikedPosts(String accountDid) => (delete(likedPosts)..where((l) => l.accountDid.equals(accountDid))).go(); + Future> searchPostsByKeyword({ + required String accountDid, + required String query, + String? source, + int limit = 20, + }) async { + final ftsQuery = _buildFtsQuery(query); + if (ftsQuery == null || limit <= 0) { + return const []; + } + + final sourceFilter = source == 'saved' || source == 'liked' ? source : null; + final rows = await customSelect( + ''' + SELECT post_uri, source, bm25(post_search_fts, 8.0, 1.0) AS rank + FROM post_search_fts + WHERE account_did = ? + ${sourceFilter == null ? '' : 'AND source = ?'} + AND post_search_fts MATCH ? + ORDER BY rank ASC + LIMIT ? + ''', + variables: [ + Variable(accountDid), + if (sourceFilter != null) Variable(sourceFilter), + Variable(ftsQuery), + Variable(limit), + ], + ).get(); + return _mapKeywordPostMatches(rows); + } + + List _mapKeywordPostMatches(List rows) { + return rows + .map( + (row) => KeywordPostMatch( + postUri: row.read('post_uri'), + source: row.read('source'), + rank: row.read('rank'), + ), + ) + .toList(growable: false); + } + + Future _createPostSearchFtsSchema() async { + await customStatement(''' + CREATE VIRTUAL TABLE IF NOT EXISTS post_search_fts USING fts5( + account_did UNINDEXED, + source UNINDEXED, + post_uri UNINDEXED, + handle, + content, + tokenize = 'unicode61' + ) + '''); + + await customStatement(''' + CREATE TRIGGER IF NOT EXISTS saved_posts_ai + AFTER INSERT ON saved_posts BEGIN + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) + VALUES ( + new.account_did, + 'saved', + new.post_uri, + coalesce(json_extract(new.post_json, '\$.author.handle'), ''), + coalesce(json_extract(new.post_json, '\$.record.text'), '') + ); + END + '''); + + await customStatement(''' + CREATE TRIGGER IF NOT EXISTS saved_posts_au + AFTER UPDATE ON saved_posts BEGIN + DELETE FROM post_search_fts + WHERE account_did = old.account_did AND source = 'saved' AND post_uri = old.post_uri; + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) + VALUES ( + new.account_did, + 'saved', + new.post_uri, + coalesce(json_extract(new.post_json, '\$.author.handle'), ''), + coalesce(json_extract(new.post_json, '\$.record.text'), '') + ); + END + '''); + + await customStatement(''' + CREATE TRIGGER IF NOT EXISTS saved_posts_ad + AFTER DELETE ON saved_posts BEGIN + DELETE FROM post_search_fts + WHERE account_did = old.account_did AND source = 'saved' AND post_uri = old.post_uri; + END + '''); + + await customStatement(''' + CREATE TRIGGER IF NOT EXISTS liked_posts_ai + AFTER INSERT ON liked_posts BEGIN + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) + VALUES ( + new.account_did, + 'liked', + new.post_uri, + coalesce(json_extract(new.post_json, '\$.post.author.handle'), coalesce(json_extract(new.post_json, '\$.author.handle'), '')), + coalesce(json_extract(new.post_json, '\$.post.record.text'), coalesce(json_extract(new.post_json, '\$.record.text'), '')) + ); + END + '''); + + await customStatement(''' + CREATE TRIGGER IF NOT EXISTS liked_posts_au + AFTER UPDATE ON liked_posts BEGIN + DELETE FROM post_search_fts + WHERE account_did = old.account_did AND source = 'liked' AND post_uri = old.post_uri; + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) + VALUES ( + new.account_did, + 'liked', + new.post_uri, + coalesce(json_extract(new.post_json, '\$.post.author.handle'), coalesce(json_extract(new.post_json, '\$.author.handle'), '')), + coalesce(json_extract(new.post_json, '\$.post.record.text'), coalesce(json_extract(new.post_json, '\$.record.text'), '')) + ); + END + '''); + + await customStatement(''' + CREATE TRIGGER IF NOT EXISTS liked_posts_ad + AFTER DELETE ON liked_posts BEGIN + DELETE FROM post_search_fts + WHERE account_did = old.account_did AND source = 'liked' AND post_uri = old.post_uri; + END + '''); + } + + Future _rebuildPostSearchFts() async { + await customStatement('DELETE FROM post_search_fts'); + await customStatement(''' + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) + SELECT + account_did, + 'saved', + post_uri, + coalesce(json_extract(post_json, '\$.author.handle'), ''), + coalesce(json_extract(post_json, '\$.record.text'), '') + FROM saved_posts + '''); + await customStatement(''' + INSERT INTO post_search_fts(account_did, source, post_uri, handle, content) + SELECT + account_did, + 'liked', + post_uri, + coalesce(json_extract(post_json, '\$.post.author.handle'), coalesce(json_extract(post_json, '\$.author.handle'), '')), + coalesce(json_extract(post_json, '\$.post.record.text'), coalesce(json_extract(post_json, '\$.record.text'), '')) + FROM liked_posts + '''); + } + + static String? _buildFtsQuery(String rawQuery) { + final tokens = RegExp(r'[A-Za-z0-9_]+').allMatches(rawQuery.toLowerCase()).map((m) => m.group(0)!).toList(); + if (tokens.isEmpty) { + return null; + } + return tokens.map((token) => '$token*').join(' AND '); + } + Future recordNotificationDelivery({ required String accountDid, required String notificationUri, diff --git a/lib/features/search/cubit/semantic_search_cubit.dart b/lib/features/search/cubit/semantic_search_cubit.dart index a0d0e69..419211c 100644 --- a/lib/features/search/cubit/semantic_search_cubit.dart +++ b/lib/features/search/cubit/semantic_search_cubit.dart @@ -76,14 +76,14 @@ class SemanticSearchCubit extends Cubit { /// Queue a search for [query], debounced by [_debounceDuration]. /// /// An empty query clears results immediately without waiting for the debounce. - /// Attempts to recover service availability before searching. + /// The repository merges keyword and semantic results. void search(String query) { _debounce?.cancel(); if (query.trim().isEmpty) { emit(SemanticSearchState(scope: state.scope)); return; } - _debounce = Timer(_debounceDuration, () => unawaited(_searchWithAvailability(query))); + _debounce = Timer(_debounceDuration, () => unawaited(_doSearch(query))); } /// Change the search scope and immediately re-run the current query. @@ -92,11 +92,7 @@ class SemanticSearchCubit extends Cubit { emit(state.copyWith(scope: scope)); if (state.query.trim().isNotEmpty) { _debounce?.cancel(); - if (await _ensureAvailable()) { - await _doSearch(state.query); - } else if (!isClosed) { - emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Semantic model unavailable.')); - } + await _doSearch(state.query); } } @@ -106,23 +102,17 @@ class SemanticSearchCubit extends Cubit { emit(SemanticSearchState(scope: state.scope)); } - Future _ensureAvailable() async { - if (_embeddingService.isAvailable) return true; - await _embeddingService.initialize(); - return _embeddingService.isAvailable; - } - - Future _searchWithAvailability(String query) async { - if (await _ensureAvailable()) { - await _doSearch(query); - } else if (!isClosed) { - emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Semantic model unavailable.')); - } + Future _warmSemanticModel() async { + if (_embeddingService.isAvailable) return; + try { + await _embeddingService.initialize(); + } catch (_) {} } Future _doSearch(String query) async { emit(state.copyWith(status: SemanticSearchStatus.searching, query: query)); try { + await _warmSemanticModel(); final source = switch (state.scope) { SearchScope.saved => 'saved', SearchScope.liked => 'liked', diff --git a/lib/features/search/data/semantic_search_repository.dart b/lib/features/search/data/semantic_search_repository.dart index b8fd987..fc1ffe9 100644 --- a/lib/features/search/data/semantic_search_repository.dart +++ b/lib/features/search/data/semantic_search_repository.dart @@ -5,9 +5,8 @@ import 'package:lazurite/features/search/data/semantic_search_result.dart'; /// Performs on-device semantic (vector) search over a user's saved and liked posts. /// -/// Embeds the query using [EmbeddingService], runs an HNSW nearest-neighbour -/// search via ObjectBox, then joins each result back to [AppDatabase] to -/// hydrate the full post JSON for display. +/// Runs keyword matching first (handle + content), then augments with +/// semantic nearest-neighbour matches when embeddings are available. class SemanticSearchRepository { SemanticSearchRepository({ required EmbeddingService embeddingService, @@ -34,11 +33,61 @@ class SemanticSearchRepository { String? source, int maxResults = 20, }) async { - if (!_embeddingService.isAvailable) return const []; - if (query.trim().isEmpty) return const []; + final normalizedQuery = query.trim(); + if (normalizedQuery.isEmpty || maxResults <= 0) return const []; - final queryVector = await _embeddingService.embed(query); + final keywordResults = await _keywordSearch(normalizedQuery, accountDid, source: source, maxResults: maxResults); + if (!_embeddingService.isAvailable) { + return keywordResults.take(maxResults).toList(growable: false); + } + + try { + final semanticResults = await _semanticSearch( + normalizedQuery, + accountDid, + source: source, + maxResults: maxResults, + ); + return _mergeResults(keywordResults, semanticResults, maxResults); + } catch (_) { + return keywordResults.take(maxResults).toList(growable: false); + } + } + + Future> _keywordSearch( + String query, + String accountDid, { + String? source, + required int maxResults, + }) async { + final matches = await _database.searchPostsByKeyword( + accountDid: accountDid, + query: query, + source: source, + limit: maxResults, + ); + + final results = []; + for (var index = 0; index < matches.length; index++) { + final match = matches[index]; + final postJson = await _fetchPostJson(accountDid, match.postUri, match.source); + if (postJson == null) { + continue; + } + // FTS rank determines order; map position to a readable confidence range. + final score = (90.0 - (index * 2.5)).clamp(55.0, 95.0).toDouble(); + results.add(SemanticSearchResult(postUri: match.postUri, score: score, source: match.source, postJson: postJson)); + } + return results; + } + Future> _semanticSearch( + String query, + String accountDid, { + String? source, + required int maxResults, + }) async { + final queryVector = await _embeddingService.embed(query); final rawResults = _embeddingRepository.nearestNeighbors( queryVector, accountDid, @@ -50,19 +99,42 @@ class SemanticSearchRepository { for (final result in rawResults) { final post = result.object; final similarity = (1.0 - result.score).clamp(0.0, 1.0); - final scorePercent = similarity * 100.0; - + final scorePercent = (similarity * 99.0) + 1.0; final postJson = await _fetchPostJson(accountDid, post.postUri, post.source); if (postJson == null) continue; - results.add( SemanticSearchResult(postUri: post.postUri, score: scorePercent, source: post.source, postJson: postJson), ); } - return results; } + List _mergeResults( + List keywordResults, + List semanticResults, + int maxResults, + ) { + final merged = []; + final seen = {}; + + void addUnique(SemanticSearchResult result) { + final key = '${result.source}|${result.postUri}'; + if (!seen.add(key)) return; + merged.add(result); + } + + for (final result in keywordResults) { + addUnique(result); + if (merged.length >= maxResults) return merged; + } + for (final result in semanticResults) { + addUnique(result); + if (merged.length >= maxResults) return merged; + } + + return merged; + } + Future _fetchPostJson(String accountDid, String postUri, String source) async { if (source == 'saved') { final entry = await _database.getSavedPost(accountDid, postUri); diff --git a/lib/features/search/presentation/semantic_search_tab.dart b/lib/features/search/presentation/semantic_search_tab.dart index c708b6d..b7fc68e 100644 --- a/lib/features/search/presentation/semantic_search_tab.dart +++ b/lib/features/search/presentation/semantic_search_tab.dart @@ -29,6 +29,7 @@ class SemanticSearchTab extends StatefulWidget { class _SemanticSearchTabState extends State { final TextEditingController _controller = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); @override void initState() { @@ -46,55 +47,122 @@ class _SemanticSearchTabState extends State { @override void dispose() { _controller.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return BlocBuilder( - builder: (context, _) { - return Column( - children: [ - BlocBuilder( - builder: (context, indexState) { - return _IndexControls(indexState: indexState); - }, - ), - _SearchBar( - controller: _controller, - onChanged: (query) => context.read().search(query), - onClear: () { - _controller.clear(); - context.read().clearResults(); - }, - ), - _ScopeChips( - selected: state.scope, - onSelected: (scope) async { - await context.read().setScope(scope); - if (context.mounted) { - unawaited(context.read().setSearchScope(scope)); - } - }, - ), - const Divider(height: 1), - Expanded(child: _ResultsView(state: state)), - ], - ); - }, - ); + return BlocListener( + listenWhen: (previous, current) => + previous.searchScope != current.searchScope || + previous.semanticSearchMaxResults != current.semanticSearchMaxResults, + listener: (context, settingsState) { + context.read().setMaxResults(settingsState.semanticSearchMaxResults); + unawaited(context.read().setScope(settingsState.searchScope)); }, + child: Column( + children: [ + BlocBuilder( + builder: (context, indexState) { + return _SearchInputRow( + controller: _controller, + focusNode: _searchFocusNode, + onChanged: (query) => context.read().search(query), + onClear: () { + _controller.clear(); + context.read().clearResults(); + }, + indexState: indexState, + ); + }, + ), + BlocBuilder( + builder: (context, indexState) { + return BlocSelector( + selector: (state) => state.scope, + builder: (context, selectedScope) { + return _ScopeRow( + selected: selectedScope, + indexState: indexState, + onSelected: (scope) async { + await context.read().setScope(scope); + if (context.mounted) { + unawaited(context.read().setSearchScope(scope)); + } + }, + ); + }, + ); + }, + ), + const Divider(height: 1), + Expanded( + child: BlocBuilder( + buildWhen: (previous, current) => + previous.status != current.status || + previous.results != current.results || + previous.errorMessage != current.errorMessage, + builder: (context, state) => _ResultsView(state: state), + ), + ), + ], + ), ); } } -class _IndexControls extends StatelessWidget { - const _IndexControls({required this.indexState}); +class _SearchInputRow extends StatelessWidget { + const _SearchInputRow({ + required this.controller, + required this.focusNode, + required this.onChanged, + required this.onClear, + required this.indexState, + }); + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final VoidCallback onClear; final SemanticIndexState indexState; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _SearchBar(controller: controller, focusNode: focusNode, onChanged: onChanged, onClear: onClear), + ), + const SizedBox(width: 8), + PopupMenuButton<_IndexMenuAction>( + tooltip: 'Search index actions', + icon: const Icon(Icons.more_vert), + onSelected: (action) => unawaited(_onMenuSelected(context, action)), + itemBuilder: (context) => [ + const PopupMenuItem<_IndexMenuAction>( + value: _IndexMenuAction.semanticSettings, + child: Text('Semantic settings'), + ), + const PopupMenuItem<_IndexMenuAction>( + value: _IndexMenuAction.refreshCount, + child: Text('Refresh indexed count'), + ), + PopupMenuItem<_IndexMenuAction>( + value: _IndexMenuAction.reindex, + enabled: !indexState.isBackfilling, + child: const Text('Re-index posts'), + ), + ], + ), + ], + ), + ); + } + Future _onMenuSelected(BuildContext context, _IndexMenuAction action) async { final cubit = context.read(); switch (action) { @@ -114,67 +182,90 @@ class _IndexControls extends StatelessWidget { break; } } +} + +class _ScopeRow extends StatelessWidget { + const _ScopeRow({required this.selected, required this.onSelected, required this.indexState}); + + final SearchScope selected; + final ValueChanged onSelected; + final SemanticIndexState indexState; @override Widget build(BuildContext context) { final scheme = context.colorScheme; - final completed = indexState.backfillCompleted ?? 0; - final total = indexState.backfillTotal ?? 0; - final progress = total > 0 ? completed / total : 0.0; - return Container( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), - color: scheme.surface, - child: Column( + final scopeOrder = [SearchScope.both, SearchScope.saved, SearchScope.liked]; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 8), + child: Row( children: [ - Row( - children: [ - Expanded( - child: Text( - indexState.isBackfilling - ? 'Indexing: ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0} posts...' - : '${indexState.indexedCount} posts indexed', - style: context.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), - ), - ), - PopupMenuButton<_IndexMenuAction>( - tooltip: 'Search index actions', - icon: const Icon(Icons.more_vert), - onSelected: (action) => unawaited(_onMenuSelected(context, action)), - itemBuilder: (context) => [ - const PopupMenuItem<_IndexMenuAction>( - value: _IndexMenuAction.semanticSettings, - child: Text('Semantic settings'), - ), - const PopupMenuItem<_IndexMenuAction>( - value: _IndexMenuAction.refreshCount, - child: Text('Refresh indexed count'), - ), - PopupMenuItem<_IndexMenuAction>( - value: _IndexMenuAction.reindex, - enabled: !indexState.isBackfilling, - child: const Text('Re-index posts'), - ), - ], - ), - ], - ), - if (indexState.isBackfilling) ...[ - const SizedBox(height: 4), - LinearProgressIndicator(value: progress > 0 ? progress : null), - ], - if (indexState.status == SemanticIndexStatus.error && indexState.errorMessage != null) ...[ - const SizedBox(height: 6), - Align( - alignment: Alignment.centerLeft, - child: Text(indexState.errorMessage!, style: context.textTheme.bodySmall?.copyWith(color: scheme.error)), + for (var index = 0; index < scopeOrder.length; index++) ...[ + _ScopeChip( + label: _ScopeChips.labels[scopeOrder[index]]!, + isSelected: selected == scopeOrder[index], + onTap: () => onSelected(scopeOrder[index]), ), + if (index < scopeOrder.length - 1) const SizedBox(width: 8), ], + const Spacer(), + Text( + indexState.isBackfilling + ? 'Indexing ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0}' + : '${indexState.indexedCount} indexed', + style: context.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), + ), ], ), ); } } +class _SearchBar extends StatelessWidget { + const _SearchBar({required this.controller, required this.focusNode, required this.onChanged, required this.onClear}); + + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final scheme = context.colorScheme; + return TextField( + focusNode: focusNode, + controller: controller, + onChanged: onChanged, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search saved and liked posts...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: controller.text.isNotEmpty + ? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: onClear, tooltip: 'Clear') + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(99), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(99), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(99), + borderSide: BorderSide(color: scheme.primary), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + isDense: true, + ), + ); + } +} + +class _ScopeChips { + static const labels = {SearchScope.both: 'Both', SearchScope.saved: 'Saved', SearchScope.liked: 'Liked'}; +} + enum _IndexMenuAction { semanticSettings, refreshCount, reindex } class _SemanticSettingsSheet extends StatelessWidget { @@ -248,72 +339,6 @@ class _SemanticSettingsSheet extends StatelessWidget { } } -class _SearchBar extends StatelessWidget { - const _SearchBar({required this.controller, required this.onChanged, required this.onClear}); - - final TextEditingController controller; - final ValueChanged onChanged; - final VoidCallback onClear; - - @override - Widget build(BuildContext context) { - final scheme = context.colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), - child: TextField( - controller: controller, - onChanged: onChanged, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Search your saved posts...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: controller.text.isNotEmpty - ? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: onClear, tooltip: 'Clear') - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(99), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(99), - borderSide: BorderSide(color: scheme.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(99), - borderSide: BorderSide(color: scheme.primary), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - isDense: true, - ), - ), - ); - } -} - -class _ScopeChips extends StatelessWidget { - const _ScopeChips({required this.selected, required this.onSelected}); - - final SearchScope selected; - final ValueChanged onSelected; - - static const _labels = {SearchScope.both: 'Both', SearchScope.saved: 'Saved', SearchScope.liked: 'Liked'}; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), - child: Row( - children: [ - for (final scope in SearchScope.values) ...[ - _ScopeChip(label: _labels[scope]!, isSelected: selected == scope, onTap: () => onSelected(scope)), - if (scope != SearchScope.liked) const SizedBox(width: 8), - ], - ], - ), - ); - } -} - class _ScopeChip extends StatelessWidget { const _ScopeChip({required this.label, required this.isSelected, required this.onTap}); @@ -495,25 +520,35 @@ class _EmptyQueryView extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = context.colorScheme; - return Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.travel_explore_outlined, size: 64, color: scheme.outline), - const SizedBox(height: 16), - Text('Search by meaning', style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text( - 'Search your saved and liked posts by meaning, not just keywords', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight - 64), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.travel_explore_outlined, size: 64, color: scheme.outline), + const SizedBox(height: 16), + Text( + 'Search your saved & liked posts', + style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 8), + Text( + 'Find posts by handle, text, and semantic similarity', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), + ), + ], + ), ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/test/features/search/cubit/semantic_search_cubit_test.dart b/test/features/search/cubit/semantic_search_cubit_test.dart index 8639430..e4a0ad1 100644 --- a/test/features/search/cubit/semantic_search_cubit_test.dart +++ b/test/features/search/cubit/semantic_search_cubit_test.dart @@ -235,25 +235,31 @@ void main() { ); blocTest( - 'emits error when service is not available', - build: () => - SemanticSearchCubit( - repository: mockRepo, - embeddingService: _unavailableService(), - accountDid: _accountDid, - debounceDuration: Duration.zero, + 'still searches when service is not available', + build: () => SemanticSearchCubit( + repository: mockRepo, + embeddingService: _unavailableService(), + accountDid: _accountDid, + debounceDuration: Duration.zero, + ), + setUp: () { + when( + () => mockRepo.search( + any(), + any(), + source: any(named: 'source'), + maxResults: any(named: 'maxResults'), ), + ).thenAnswer((_) async => const []); + }, act: (cubit) => cubit.search('flutter'), expect: () => [ - predicate( - (s) => - s.status == SemanticSearchStatus.error && - (s.errorMessage?.contains('Semantic model unavailable') ?? false), - ), + predicate((s) => s.status == SemanticSearchStatus.searching), + predicate((s) => s.status == SemanticSearchStatus.loaded && s.results.isEmpty), ], - verify: (cubit) { - verifyNever(() => mockRepo.search(any(), any())); - }, + verify: (_) => verify( + () => mockRepo.search('flutter', _accountDid, source: null, maxResults: any(named: 'maxResults')), + ).called(1), ); }); diff --git a/test/features/search/data/semantic_search_repository_test.dart b/test/features/search/data/semantic_search_repository_test.dart index a3074b0..ab4d23b 100644 --- a/test/features/search/data/semantic_search_repository_test.dart +++ b/test/features/search/data/semantic_search_repository_test.dart @@ -133,13 +133,15 @@ void main() { group('SemanticSearchRepository', () { group('search', () { - test('returns empty list when EmbeddingService is unavailable', () async { + test('returns keyword results when EmbeddingService is unavailable', () async { await insertSavedPost('at://did/post/1', 'did:plc:user'); final repo = makeRepo(service: _unavailableService()); - final results = await repo.search('hello', 'did:plc:user'); + final results = await repo.search('post text', 'did:plc:user'); - expect(results, isEmpty); + expect(results, hasLength(1)); + expect(results.first.postUri, equals('at://did/post/1')); + expect(results.first.source, equals('saved')); }); test('returns empty list when query is empty', () async { @@ -201,6 +203,28 @@ void main() { expect((decoded['post'] as Map)['uri'], equals('at://did/post/2')); }); + test('matches saved posts by author handle keyword', () async { + await insertSavedPost('at://did/post/handle', 'did:plc:user', text: 'no handle text here'); + + final repo = makeRepo(service: _unavailableService()); + final results = await repo.search('author.bsky.social', 'did:plc:user'); + + expect(results, hasLength(1)); + expect(results.first.postUri, equals('at://did/post/handle')); + expect(results.first.source, equals('saved')); + }); + + test('matches liked posts by content keyword', () async { + await insertLikedPost('at://did/post/liked-keyword', 'did:plc:user', text: 'Dart keyword search works'); + + final repo = makeRepo(service: _unavailableService()); + final results = await repo.search('keyword search', 'did:plc:user'); + + expect(results, hasLength(1)); + expect(results.first.postUri, equals('at://did/post/liked-keyword')); + expect(results.first.source, equals('liked')); + }); + test('score is in the range [0, 100]', () async { await insertSavedPost('at://did/post/1', 'did:plc:user'); @@ -247,6 +271,15 @@ void main() { expect(sources, containsAll(['saved', 'liked'])); }); + test('deduplicates combined keyword and semantic hits for the same source and post', () async { + await insertSavedPost('at://did/post/dupe', 'did:plc:user', text: 'keyword and semantic'); + + final repo = makeRepo(); + final results = await repo.search('keyword', 'did:plc:user'); + + expect(results.where((result) => result.postUri == 'at://did/post/dupe' && result.source == 'saved').length, 1); + }); + test('filters to saved posts when source is "saved"', () async { await insertSavedPost('at://did/post/saved', 'did:plc:user'); await insertLikedPost('at://did/post/liked', 'did:plc:user'); diff --git a/test/features/search/presentation/semantic_search_tab_test.dart b/test/features/search/presentation/semantic_search_tab_test.dart index 33efd58..4a08bcc 100644 --- a/test/features/search/presentation/semantic_search_tab_test.dart +++ b/test/features/search/presentation/semantic_search_tab_test.dart @@ -108,8 +108,8 @@ void main() { group('SemanticSearchTab', () { testWidgets('shows empty query state when no query entered', (tester) async { await tester.pumpWidget(buildSubject()); - expect(find.text('Search by meaning'), findsOneWidget); - expect(find.text('Search your saved and liked posts by meaning, not just keywords'), findsOneWidget); + expect(find.text('Search your saved & liked posts'), findsOneWidget); + expect(find.text('Find posts by handle, text, and semantic similarity'), findsOneWidget); }); testWidgets('shows loading indicator while searching', (tester) async { @@ -222,12 +222,12 @@ void main() { ), ); await tester.pumpWidget(buildSubject()); - expect(find.text('Indexing: 42/100 posts...'), findsOneWidget); + expect(find.text('Indexing 42/100'), findsOneWidget); }); testWidgets('shows indexed count header when idle', (tester) async { await tester.pumpWidget(buildSubject()); - expect(find.text('0 posts indexed'), findsOneWidget); + expect(find.text('0 indexed'), findsOneWidget); }); testWidgets('kebab menu refresh action triggers loadCount', (tester) async {