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: add semantic search screen with settings

+1237 -119
+25 -25
docs/tasks/phase-7.md
··· 69 69 70 70 #### Semantic Search Tab 71 71 72 - - [ ] Saved posts screen - add "Search" tab alongside existing "All Saved" tab 73 - - [ ] Search text field with hint "Search your saved posts..." 74 - - [ ] Scope toggle chips: "Saved" / "Liked" / "Both" (default: Both) 75 - - [ ] Results list - reuse `PostCard`, ordered by similarity score 76 - - [ ] Relevance badge on each result (percentage) 77 - - [ ] Empty state (no query): "Search your saved and liked posts by meaning, not just keywords" 78 - - [ ] No results state: "No similar posts found" 79 - - [ ] Unavailable state: shown when `EmbeddingService.isAvailable` is false, with explanation 72 + - [x] Saved posts screen - add "Search" tab alongside existing "All Saved" tab 73 + - [x] Search text field with hint "Search your saved posts..." 74 + - [x] Scope toggle chips: "Saved" / "Liked" / "Both" (default: Both) 75 + - [x] Results list - reuse `PostCard`, ordered by similarity score 76 + - [x] Relevance badge on each result (percentage) 77 + - [x] Empty state (no query): "Search your saved and liked posts by meaning, not just keywords" 78 + - [x] No results state: "No similar posts found" 79 + - [x] Unavailable state: shown when `EmbeddingService.isAvailable` is false, with explanation 80 80 81 81 #### Settings 82 82 83 - - [ ] Settings screen - new "Search" section 84 - - [ ] "Semantic Search" toggle (default: off) - enables feature, triggers backfill on first enable 85 - - [ ] "Search scope" dropdown - Saved only / Liked only / Both 86 - - [ ] "Index status" tile - shows indexed post count, "Re-index" button 87 - - [ ] "Max results" slider - 10 to 50, default 20 88 - - [ ] Backfill progress indicator - "Indexing: 142/300 posts..." shown during backfill 83 + - [x] Settings screen - new "Search" section 84 + - [x] "Semantic Search" toggle (default: off) - enables feature, triggers backfill on first enable 85 + - [x] "Search scope" dropdown - Saved only / Liked only / Both 86 + - [x] "Index status" tile - shows indexed post count, "Re-index" button 87 + - [x] "Max results" slider - 10 to 50, default 20 88 + - [x] Backfill progress indicator - "Indexing: 142/300 posts..." shown during backfill 89 89 90 90 ### Tests 91 91 92 - - [ ] Unit tests: `WordPieceTokenizer` - tokenization, padding, truncation, edge cases (empty string, very long text) 93 - - [ ] Unit tests: `EmbeddingService` - initialization, embed returns correct dimensions, L2 normalization, dispose cleanup 94 - - [ ] Unit tests: `PostTextExtractor` - text concatenation from various post shapes (text-only, images with alt, link cards, combinations) 95 - - [ ] Unit tests: `EmbeddingRepository` - upsert, delete, query by account, count 96 - - [ ] Unit tests: `LikedPostsRepository` - sync pagination, dedup on known URI, 1000-cap eviction 97 - - [ ] Unit tests: `SemanticIndexer` - index/remove/backfill, progress stream, integration with save/like hooks 98 - - [ ] Unit tests: `SemanticSearchRepository` - search returns scored results, scope filtering, account isolation 99 - - [ ] Unit tests: `SemanticSearchCubit` - debounce, state transitions, scope changes 100 - - [ ] Unit tests: `SemanticIndexCubit` - backfill progress, reindex trigger 101 - - [ ] Widget tests: search tab renders, query produces results, scope chips filter, relevance badges display, empty/no-results/unavailable states 102 - - [ ] Widget tests: settings section renders, toggle enables/disables, progress indicator during backfill, re-index button triggers reindex 92 + - [x] Unit tests: `WordPieceTokenizer` - tokenization, padding, truncation, edge cases (empty string, very long text) 93 + - [x] Unit tests: `EmbeddingService` - initialization, embed returns correct dimensions, L2 normalization, dispose cleanup 94 + - [x] Unit tests: `PostTextExtractor` - text concatenation from various post shapes (text-only, images with alt, link cards, combinations) 95 + - [x] Unit tests: `EmbeddingRepository` - upsert, delete, query by account, count 96 + - [x] Unit tests: `LikedPostsRepository` - sync pagination, dedup on known URI, 1000-cap eviction 97 + - [x] Unit tests: `SemanticIndexer` - index/remove/backfill, progress stream, integration with save/like hooks 98 + - [x] Unit tests: `SemanticSearchRepository` - search returns scored results, scope filtering, account isolation 99 + - [x] Unit tests: `SemanticSearchCubit` - debounce, state transitions, scope changes 100 + - [x] Unit tests: `SemanticIndexCubit` - backfill progress, reindex trigger 101 + - [x] Widget tests: search tab renders, query produces results, scope chips filter, relevance badges display, empty/no-results/unavailable states 102 + - [x] Widget tests: settings section renders, toggle enables/disables, progress indicator during backfill, re-index button triggers reindex 103 103 - [ ] Integration test: save a post → verify it appears in semantic search results for a relevant query
+2 -4
lib/features/feed/cubit/liked_posts_sync_cubit.dart
··· 12 12 13 13 bool get isSyncing => status == LikedPostsSyncStatus.syncing; 14 14 15 - LikedPostsSyncState copyWith({LikedPostsSyncStatus? status, String? errorMessage}) => LikedPostsSyncState( 16 - status: status ?? this.status, 17 - errorMessage: errorMessage ?? this.errorMessage, 18 - ); 15 + LikedPostsSyncState copyWith({LikedPostsSyncStatus? status, String? errorMessage}) => 16 + LikedPostsSyncState(status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage); 19 17 20 18 @override 21 19 List<Object?> get props => [status, errorMessage];
+105 -77
lib/features/feed/presentation/saved_posts_screen.dart
··· 9 9 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 10 10 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 11 11 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 12 + import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 12 13 import 'package:share_plus/share_plus.dart'; 13 14 14 15 class SavedPostsScreen extends StatelessWidget { ··· 29 30 } 30 31 } 31 32 32 - class _SavedPostsContent extends StatelessWidget { 33 + class _SavedPostsContent extends StatefulWidget { 33 34 const _SavedPostsContent(); 34 35 35 36 @override 37 + State<_SavedPostsContent> createState() => _SavedPostsContentState(); 38 + } 39 + 40 + class _SavedPostsContentState extends State<_SavedPostsContent> with SingleTickerProviderStateMixin { 41 + late final TabController _tabController; 42 + 43 + @override 44 + void initState() { 45 + super.initState(); 46 + _tabController = TabController(length: 2, vsync: this); 47 + } 48 + 49 + @override 50 + void dispose() { 51 + _tabController.dispose(); 52 + super.dispose(); 53 + } 54 + 55 + @override 36 56 Widget build(BuildContext context) { 37 57 return Scaffold( 38 58 appBar: AppBar( ··· 49 69 }, 50 70 ), 51 71 ], 52 - ), 53 - body: BlocBuilder<SavedPostsCubit, SavedPostsState>( 54 - builder: (context, state) { 55 - if (state.status == SavedPostsStatus.loading) { 56 - return const Center(child: CircularProgressIndicator()); 57 - } 58 - 59 - if (state.status == SavedPostsStatus.error) { 60 - return Center( 61 - child: Column( 62 - mainAxisAlignment: MainAxisAlignment.center, 63 - children: [ 64 - const Icon(Icons.error_outline, size: 48, color: Colors.grey), 65 - const SizedBox(height: 16), 66 - Text(state.error ?? 'Failed to load saved posts'), 67 - const SizedBox(height: 16), 68 - FilledButton( 69 - onPressed: () => context.read<SavedPostsCubit>().loadSavedPosts(), 70 - child: const Text('Retry'), 71 - ), 72 - ], 73 - ), 74 - ); 75 - } 76 - 77 - if (state.savedPosts.isEmpty) { 78 - return Center( 79 - child: Column( 80 - mainAxisAlignment: MainAxisAlignment.center, 81 - children: [ 82 - Icon(Icons.bookmark_outline, size: 64, color: Theme.of(context).colorScheme.outline), 83 - const SizedBox(height: 16), 84 - Text( 85 - 'No saved posts', 86 - style: Theme.of( 87 - context, 88 - ).textTheme.headlineSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 89 - ), 90 - const SizedBox(height: 8), 91 - Text( 92 - 'Posts you save will appear here', 93 - style: Theme.of( 94 - context, 95 - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 96 - ), 97 - ], 98 - ), 99 - ); 100 - } 101 - 102 - return RefreshIndicator( 103 - onRefresh: () => context.read<SavedPostsCubit>().loadSavedPosts(), 104 - child: ListView.builder( 105 - itemCount: state.savedPosts.length, 106 - itemBuilder: (context, index) { 107 - final savedPost = state.savedPosts[index]; 108 - return _SavedPostCard( 109 - savedPost: savedPost, 110 - onUnsave: () => context.read<SavedPostsCubit>().unsavePostById(savedPost.id), 111 - ); 112 - }, 113 - ), 114 - ); 115 - }, 72 + bottom: TabBar( 73 + controller: _tabController, 74 + tabs: const [ 75 + Tab(text: 'All Saved'), 76 + Tab(text: 'Search'), 77 + ], 78 + ), 116 79 ), 80 + body: TabBarView(controller: _tabController, children: const [_AllSavedTab(), SemanticSearchTab()]), 117 81 ); 118 82 } 119 83 ··· 142 106 } 143 107 } 144 108 109 + class _AllSavedTab extends StatelessWidget { 110 + const _AllSavedTab(); 111 + 112 + @override 113 + Widget build(BuildContext context) { 114 + return BlocBuilder<SavedPostsCubit, SavedPostsState>( 115 + builder: (context, state) { 116 + if (state.status == SavedPostsStatus.loading) { 117 + return const Center(child: CircularProgressIndicator()); 118 + } 119 + 120 + if (state.status == SavedPostsStatus.error) { 121 + return Center( 122 + child: Column( 123 + mainAxisAlignment: MainAxisAlignment.center, 124 + children: [ 125 + const Icon(Icons.error_outline, size: 48, color: Colors.grey), 126 + const SizedBox(height: 16), 127 + Text(state.error ?? 'Failed to load saved posts'), 128 + const SizedBox(height: 16), 129 + FilledButton( 130 + onPressed: () => context.read<SavedPostsCubit>().loadSavedPosts(), 131 + child: const Text('Retry'), 132 + ), 133 + ], 134 + ), 135 + ); 136 + } 137 + 138 + if (state.savedPosts.isEmpty) { 139 + return Center( 140 + child: Column( 141 + mainAxisAlignment: MainAxisAlignment.center, 142 + children: [ 143 + Icon(Icons.bookmark_outline, size: 64, color: Theme.of(context).colorScheme.outline), 144 + const SizedBox(height: 16), 145 + Text( 146 + 'No saved posts', 147 + style: Theme.of( 148 + context, 149 + ).textTheme.headlineSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 150 + ), 151 + const SizedBox(height: 8), 152 + Text( 153 + 'Posts you save will appear here', 154 + style: Theme.of( 155 + context, 156 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 157 + ), 158 + ], 159 + ), 160 + ); 161 + } 162 + 163 + return RefreshIndicator( 164 + onRefresh: () => context.read<SavedPostsCubit>().loadSavedPosts(), 165 + child: ListView.builder( 166 + itemCount: state.savedPosts.length, 167 + itemBuilder: (context, index) { 168 + final savedPost = state.savedPosts[index]; 169 + return _SavedPostCard( 170 + savedPost: savedPost, 171 + onUnsave: () => context.read<SavedPostsCubit>().unsavePostById(savedPost.id), 172 + ); 173 + }, 174 + ), 175 + ); 176 + }, 177 + ); 178 + } 179 + } 180 + 145 181 class _SavedPostCard extends StatelessWidget { 146 182 const _SavedPostCard({required this.savedPost, required this.onUnsave}); 147 183 ··· 210 246 final now = DateTime.now(); 211 247 final difference = now.difference(date); 212 248 213 - if (difference.inMinutes < 1) { 214 - return 'just now'; 215 - } 216 - if (difference.inHours < 1) { 217 - return '${difference.inMinutes}m ago'; 218 - } 219 - if (difference.inDays < 1) { 220 - return '${difference.inHours}h ago'; 221 - } 222 - if (difference.inDays < 7) { 223 - return '${difference.inDays}d ago'; 224 - } 249 + if (difference.inMinutes < 1) return 'just now'; 250 + if (difference.inHours < 1) return '${difference.inMinutes}m ago'; 251 + if (difference.inDays < 1) return '${difference.inHours}h ago'; 252 + if (difference.inDays < 7) return '${difference.inDays}d ago'; 225 253 return '${date.month}/${date.day}/${date.year}'; 226 254 } 227 255
+10 -4
lib/features/search/cubit/semantic_search_cubit.dart
··· 3 3 import 'package:equatable/equatable.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:lazurite/core/embedding/embedding_service.dart'; 6 + import 'package:lazurite/features/search/data/search_scope.dart'; 6 7 import 'package:lazurite/features/search/data/semantic_search_repository.dart'; 7 8 import 'package:lazurite/features/search/data/semantic_search_result.dart'; 8 9 9 - enum SemanticSearchStatus { initial, searching, loaded, error, unavailable } 10 + export 'package:lazurite/features/search/data/search_scope.dart'; 10 11 11 - /// Scope filter for semantic search results. 12 - enum SearchScope { saved, liked, both } 12 + enum SemanticSearchStatus { initial, searching, loaded, error, unavailable } 13 13 14 14 class SemanticSearchState extends Equatable { 15 15 const SemanticSearchState({ ··· 56 56 required SemanticSearchRepository repository, 57 57 required EmbeddingService embeddingService, 58 58 required String accountDid, 59 + int maxResults = 20, 59 60 Duration debounceDuration = const Duration(milliseconds: 500), 60 61 }) : _repository = repository, 61 62 _embeddingService = embeddingService, 62 63 _accountDid = accountDid, 64 + _maxResults = maxResults, 63 65 _debounceDuration = debounceDuration, 64 66 super( 65 67 embeddingService.isAvailable ··· 70 72 final SemanticSearchRepository _repository; 71 73 final EmbeddingService _embeddingService; 72 74 final String _accountDid; 75 + int _maxResults; 73 76 final Duration _debounceDuration; 74 77 Timer? _debounce; 78 + 79 + /// Update the maximum number of results returned per search. 80 + void setMaxResults(int maxResults) => _maxResults = maxResults; 75 81 76 82 /// Queue a search for [query], debounced by [_debounceDuration]. 77 83 /// ··· 114 120 SearchScope.liked => 'liked', 115 121 SearchScope.both => null, 116 122 }; 117 - final results = await _repository.search(query, _accountDid, source: source); 123 + final results = await _repository.search(query, _accountDid, source: source, maxResults: _maxResults); 118 124 if (isClosed) return; 119 125 emit(state.copyWith(status: SemanticSearchStatus.loaded, results: results)); 120 126 } catch (e) {
+3
lib/features/search/data/search_scope.dart
··· 1 + /// Scope filter for semantic search — limits results to saved posts, liked 2 + /// posts, or both. 3 + enum SearchScope { saved, liked, both }
+461
lib/features/search/presentation/semantic_search_tab.dart
··· 1 + import 'dart:convert'; 2 + 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:lazurite/core/logging/app_logger.dart'; 7 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 8 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 10 + import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 11 + import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 12 + import 'package:lazurite/features/search/data/semantic_search_result.dart'; 13 + 14 + /// The "Search" tab inside the saved posts screen. 15 + /// 16 + /// Renders a search input, scope chips, and a results list backed by 17 + /// [SemanticSearchCubit]. Requires [SemanticSearchCubit], 18 + /// [SemanticIndexCubit], [PostActionRepository], [PostActionCache], and 19 + /// a [String] account DID to be available in the widget tree. 20 + class SemanticSearchTab extends StatefulWidget { 21 + const SemanticSearchTab({super.key}); 22 + 23 + @override 24 + State<SemanticSearchTab> createState() => _SemanticSearchTabState(); 25 + } 26 + 27 + class _SemanticSearchTabState extends State<SemanticSearchTab> { 28 + final TextEditingController _controller = TextEditingController(); 29 + 30 + @override 31 + void dispose() { 32 + _controller.dispose(); 33 + super.dispose(); 34 + } 35 + 36 + @override 37 + Widget build(BuildContext context) { 38 + return BlocBuilder<SemanticSearchCubit, SemanticSearchState>( 39 + builder: (context, state) { 40 + if (state.status == SemanticSearchStatus.unavailable) { 41 + return const _UnavailableView(); 42 + } 43 + 44 + return Column( 45 + children: [ 46 + BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 47 + builder: (context, indexState) { 48 + if (!indexState.isBackfilling) return const SizedBox.shrink(); 49 + return _BackfillBanner(indexState: indexState); 50 + }, 51 + ), 52 + _SearchBar( 53 + controller: _controller, 54 + onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 55 + onClear: () { 56 + _controller.clear(); 57 + context.read<SemanticSearchCubit>().clearResults(); 58 + }, 59 + ), 60 + _ScopeChips(selected: state.scope, onSelected: context.read<SemanticSearchCubit>().setScope), 61 + const Divider(height: 1), 62 + Expanded(child: _ResultsView(state: state)), 63 + ], 64 + ); 65 + }, 66 + ); 67 + } 68 + } 69 + 70 + class _SearchBar extends StatelessWidget { 71 + const _SearchBar({required this.controller, required this.onChanged, required this.onClear}); 72 + 73 + final TextEditingController controller; 74 + final ValueChanged<String> onChanged; 75 + final VoidCallback onClear; 76 + 77 + @override 78 + Widget build(BuildContext context) { 79 + final scheme = Theme.of(context).colorScheme; 80 + return Padding( 81 + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), 82 + child: TextField( 83 + controller: controller, 84 + onChanged: onChanged, 85 + textInputAction: TextInputAction.search, 86 + decoration: InputDecoration( 87 + hintText: 'Search your saved posts...', 88 + prefixIcon: const Icon(Icons.search, size: 20), 89 + suffixIcon: controller.text.isNotEmpty 90 + ? IconButton(icon: const Icon(Icons.clear, size: 18), onPressed: onClear, tooltip: 'Clear') 91 + : null, 92 + border: OutlineInputBorder( 93 + borderRadius: BorderRadius.circular(99), 94 + borderSide: BorderSide(color: scheme.outlineVariant), 95 + ), 96 + enabledBorder: OutlineInputBorder( 97 + borderRadius: BorderRadius.circular(99), 98 + borderSide: BorderSide(color: scheme.outlineVariant), 99 + ), 100 + focusedBorder: OutlineInputBorder( 101 + borderRadius: BorderRadius.circular(99), 102 + borderSide: BorderSide(color: scheme.primary), 103 + ), 104 + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 105 + isDense: true, 106 + ), 107 + ), 108 + ); 109 + } 110 + } 111 + 112 + class _ScopeChips extends StatelessWidget { 113 + const _ScopeChips({required this.selected, required this.onSelected}); 114 + 115 + final SearchScope selected; 116 + final ValueChanged<SearchScope> onSelected; 117 + 118 + static const _labels = {SearchScope.both: 'Both', SearchScope.saved: 'Saved', SearchScope.liked: 'Liked'}; 119 + 120 + @override 121 + Widget build(BuildContext context) { 122 + return Padding( 123 + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), 124 + child: Row( 125 + children: [ 126 + for (final scope in SearchScope.values) ...[ 127 + _ScopeChip(label: _labels[scope]!, isSelected: selected == scope, onTap: () => onSelected(scope)), 128 + if (scope != SearchScope.liked) const SizedBox(width: 8), 129 + ], 130 + ], 131 + ), 132 + ); 133 + } 134 + } 135 + 136 + class _ScopeChip extends StatelessWidget { 137 + const _ScopeChip({required this.label, required this.isSelected, required this.onTap}); 138 + 139 + final String label; 140 + final bool isSelected; 141 + final VoidCallback onTap; 142 + 143 + @override 144 + Widget build(BuildContext context) { 145 + final scheme = Theme.of(context).colorScheme; 146 + return GestureDetector( 147 + onTap: onTap, 148 + child: AnimatedContainer( 149 + duration: const Duration(milliseconds: 150), 150 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), 151 + decoration: BoxDecoration( 152 + color: isSelected ? scheme.primary : Colors.transparent, 153 + borderRadius: BorderRadius.circular(99), 154 + border: Border.all(color: isSelected ? scheme.primary : scheme.outlineVariant, width: 1.5), 155 + ), 156 + child: Text( 157 + label, 158 + style: Theme.of(context).textTheme.labelMedium?.copyWith( 159 + color: isSelected ? scheme.onPrimary : scheme.onSurfaceVariant, 160 + fontWeight: FontWeight.w600, 161 + ), 162 + ), 163 + ), 164 + ); 165 + } 166 + } 167 + 168 + class _ResultsView extends StatelessWidget { 169 + const _ResultsView({required this.state}); 170 + 171 + final SemanticSearchState state; 172 + 173 + @override 174 + Widget build(BuildContext context) { 175 + return switch (state.status) { 176 + SemanticSearchStatus.initial => const _EmptyQueryView(), 177 + SemanticSearchStatus.searching => const Center(child: CircularProgressIndicator()), 178 + SemanticSearchStatus.loaded when state.results.isEmpty => const _NoResultsView(), 179 + SemanticSearchStatus.loaded => _ResultsList(results: state.results), 180 + SemanticSearchStatus.error => _ErrorView(message: state.errorMessage), 181 + SemanticSearchStatus.unavailable => const _UnavailableView(), 182 + }; 183 + } 184 + } 185 + 186 + class _ResultsList extends StatelessWidget { 187 + const _ResultsList({required this.results}); 188 + 189 + final List<SemanticSearchResult> results; 190 + 191 + @override 192 + Widget build(BuildContext context) { 193 + return ListView.builder( 194 + itemCount: results.length, 195 + itemBuilder: (context, index) { 196 + final result = results[index]; 197 + return _ResultCard(result: result); 198 + }, 199 + ); 200 + } 201 + } 202 + 203 + class _ResultCard extends StatelessWidget { 204 + const _ResultCard({required this.result}); 205 + 206 + final SemanticSearchResult result; 207 + 208 + FeedViewPost? _toFeedViewPost() { 209 + try { 210 + final json = jsonDecode(result.postJson) as Map<String, dynamic>; 211 + if (result.source == 'liked') { 212 + return FeedViewPost.fromJson(json); 213 + } 214 + return FeedViewPost(post: PostView.fromJson(json)); 215 + } catch (e) { 216 + log.e('Failed to deserialize semantic search result', error: e); 217 + return null; 218 + } 219 + } 220 + 221 + @override 222 + Widget build(BuildContext context) { 223 + final feedViewPost = _toFeedViewPost(); 224 + final accountDid = context.read<String>(); 225 + 226 + return Column( 227 + crossAxisAlignment: CrossAxisAlignment.start, 228 + children: [ 229 + Padding( 230 + padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), 231 + child: Row( 232 + children: [ 233 + _RelevanceBadge(score: result.score), 234 + const SizedBox(width: 8), 235 + _SourceTag(source: result.source), 236 + ], 237 + ), 238 + ), 239 + if (feedViewPost != null) 240 + PostCardWithActions(feedViewPost: feedViewPost, accountDid: accountDid) 241 + else 242 + _FallbackCard(postUri: result.postUri), 243 + const Divider(height: 1), 244 + ], 245 + ); 246 + } 247 + } 248 + 249 + class _RelevanceBadge extends StatelessWidget { 250 + const _RelevanceBadge({required this.score}); 251 + 252 + final double score; 253 + 254 + @override 255 + Widget build(BuildContext context) { 256 + final (color, bg) = _colors(context); 257 + return Container( 258 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 259 + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(99)), 260 + child: Text( 261 + '${score.round()}%', 262 + style: Theme.of( 263 + context, 264 + ).textTheme.labelSmall?.copyWith(color: color, fontWeight: FontWeight.w600, fontFamily: 'JetBrains Mono'), 265 + ), 266 + ); 267 + } 268 + 269 + (Color, Color) _colors(BuildContext context) { 270 + if (score >= 75) return (Colors.green.shade700, Colors.green.shade50); 271 + if (score >= 50) return (Colors.orange.shade700, Colors.orange.shade50); 272 + return (Colors.grey.shade600, Colors.grey.shade100); 273 + } 274 + } 275 + 276 + class _SourceTag extends StatelessWidget { 277 + const _SourceTag({required this.source}); 278 + 279 + final String source; 280 + 281 + @override 282 + Widget build(BuildContext context) { 283 + final scheme = Theme.of(context).colorScheme; 284 + final (icon, label) = source == 'saved' ? (Icons.bookmark_outline, 'Saved') : (Icons.favorite_outline, 'Liked'); 285 + 286 + return Container( 287 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 288 + decoration: BoxDecoration(color: scheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)), 289 + child: Row( 290 + mainAxisSize: MainAxisSize.min, 291 + children: [ 292 + Icon(icon, size: 11, color: scheme.onSurfaceVariant), 293 + const SizedBox(width: 3), 294 + Text(label, style: Theme.of(context).textTheme.labelSmall?.copyWith(color: scheme.onSurfaceVariant)), 295 + ], 296 + ), 297 + ); 298 + } 299 + } 300 + 301 + class _FallbackCard extends StatelessWidget { 302 + const _FallbackCard({required this.postUri}); 303 + 304 + final String postUri; 305 + 306 + @override 307 + Widget build(BuildContext context) { 308 + return ListTile( 309 + leading: const Icon(Icons.article_outlined), 310 + title: const Text('Post'), 311 + subtitle: Text(postUri, maxLines: 1, overflow: TextOverflow.ellipsis), 312 + ); 313 + } 314 + } 315 + 316 + class _EmptyQueryView extends StatelessWidget { 317 + const _EmptyQueryView(); 318 + 319 + @override 320 + Widget build(BuildContext context) { 321 + final scheme = Theme.of(context).colorScheme; 322 + return Center( 323 + child: Padding( 324 + padding: const EdgeInsets.all(32), 325 + child: Column( 326 + mainAxisAlignment: MainAxisAlignment.center, 327 + children: [ 328 + Icon(Icons.travel_explore_outlined, size: 64, color: scheme.outline), 329 + const SizedBox(height: 16), 330 + Text( 331 + 'Search by meaning', 332 + style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 333 + ), 334 + const SizedBox(height: 8), 335 + Text( 336 + 'Search your saved and liked posts by meaning, not just keywords', 337 + textAlign: TextAlign.center, 338 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 339 + ), 340 + ], 341 + ), 342 + ), 343 + ); 344 + } 345 + } 346 + 347 + class _NoResultsView extends StatelessWidget { 348 + const _NoResultsView(); 349 + 350 + @override 351 + Widget build(BuildContext context) { 352 + final scheme = Theme.of(context).colorScheme; 353 + return Center( 354 + child: Column( 355 + mainAxisAlignment: MainAxisAlignment.center, 356 + children: [ 357 + Icon(Icons.search_off_outlined, size: 64, color: scheme.outline), 358 + const SizedBox(height: 16), 359 + Text( 360 + 'No similar posts found', 361 + style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 362 + ), 363 + const SizedBox(height: 8), 364 + Text( 365 + 'Try different keywords or a broader scope', 366 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 367 + ), 368 + ], 369 + ), 370 + ); 371 + } 372 + } 373 + 374 + class _ErrorView extends StatelessWidget { 375 + const _ErrorView({required this.message}); 376 + 377 + final String? message; 378 + 379 + @override 380 + Widget build(BuildContext context) { 381 + return Center( 382 + child: Column( 383 + mainAxisAlignment: MainAxisAlignment.center, 384 + children: [ 385 + const Icon(Icons.error_outline, size: 48, color: Colors.grey), 386 + const SizedBox(height: 16), 387 + Text(message ?? 'Search failed. Please try again.'), 388 + const SizedBox(height: 16), 389 + FilledButton(onPressed: () => context.read<SemanticSearchCubit>().clearResults(), child: const Text('Clear')), 390 + ], 391 + ), 392 + ); 393 + } 394 + } 395 + 396 + class _UnavailableView extends StatelessWidget { 397 + const _UnavailableView(); 398 + 399 + @override 400 + Widget build(BuildContext context) { 401 + final scheme = Theme.of(context).colorScheme; 402 + return Center( 403 + child: Padding( 404 + padding: const EdgeInsets.all(32), 405 + child: Column( 406 + mainAxisAlignment: MainAxisAlignment.center, 407 + children: [ 408 + Icon(Icons.model_training_outlined, size: 64, color: scheme.outline), 409 + const SizedBox(height: 16), 410 + Text( 411 + 'Semantic search unavailable', 412 + style: Theme.of(context).textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 413 + ), 414 + const SizedBox(height: 8), 415 + Text( 416 + 'The on-device language model could not be loaded on this device.', 417 + textAlign: TextAlign.center, 418 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 419 + ), 420 + ], 421 + ), 422 + ), 423 + ); 424 + } 425 + } 426 + 427 + class _BackfillBanner extends StatelessWidget { 428 + const _BackfillBanner({required this.indexState}); 429 + 430 + final SemanticIndexState indexState; 431 + 432 + @override 433 + Widget build(BuildContext context) { 434 + final scheme = Theme.of(context).colorScheme; 435 + final completed = indexState.backfillCompleted ?? 0; 436 + final total = indexState.backfillTotal ?? 0; 437 + final progress = (total > 0) ? completed / total : 0.0; 438 + 439 + return Container( 440 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), 441 + color: scheme.surfaceContainerLow, 442 + child: Row( 443 + children: [ 444 + Expanded( 445 + child: Column( 446 + crossAxisAlignment: CrossAxisAlignment.start, 447 + children: [ 448 + Text( 449 + 'Indexing: $completed/$total posts...', 450 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), 451 + ), 452 + const SizedBox(height: 4), 453 + LinearProgressIndicator(value: progress > 0 ? progress : null), 454 + ], 455 + ), 456 + ), 457 + ], 458 + ), 459 + ); 460 + } 461 + }
+25
lib/features/settings/bloc/settings_cubit.dart
··· 2 2 import 'package:lazurite/core/database/app_database.dart'; 3 3 import 'package:lazurite/core/theme/app_theme.dart'; 4 4 import 'package:lazurite/core/theme/feed_layout.dart'; 5 + import 'package:lazurite/features/search/data/search_scope.dart'; 5 6 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 6 7 7 8 class SettingsCubit extends Cubit<SettingsState> { ··· 37 38 static const String _keyThreadAutoCollapseDepth = 'thread_auto_collapse_depth'; 38 39 static const String _keyConstellationUrl = 'constellation_url'; 39 40 static const String _defaultConstellationUrl = 'https://constellation.microcosm.blue'; 41 + static const String _keySemanticSearchEnabled = 'semantic_search_enabled'; 42 + static const String _keySearchScope = 'search_scope'; 43 + static const String _keySemanticSearchMaxResults = 'semantic_search_max_results'; 40 44 41 45 Future<void> loadSettings() async { 42 46 final paletteStr = await database.getSetting(_keyThemePalette); ··· 47 51 final simulateOfflineStr = await database.getSetting(_keySimulateOffline); 48 52 final threadAutoCollapseDepthStr = await database.getSetting(_keyThreadAutoCollapseDepth); 49 53 final constellationUrlStr = await database.getSetting(_keyConstellationUrl); 54 + final semanticSearchEnabledStr = await database.getSetting(_keySemanticSearchEnabled); 55 + final searchScopeStr = await database.getSetting(_keySearchScope); 56 + final semanticSearchMaxResultsStr = await database.getSetting(_keySemanticSearchMaxResults); 50 57 51 58 emit( 52 59 state.copyWith( ··· 57 64 simulateOffline: simulateOfflineStr == 'true', 58 65 threadAutoCollapseDepth: int.tryParse(threadAutoCollapseDepthStr ?? ''), 59 66 constellationUrl: constellationUrlStr ?? _defaultConstellationUrl, 67 + semanticSearchEnabled: semanticSearchEnabledStr == 'true', 68 + searchScope: SearchScope.values.firstWhere((s) => s.name == searchScopeStr, orElse: () => SearchScope.both), 69 + semanticSearchMaxResults: int.tryParse(semanticSearchMaxResultsStr ?? '') ?? 20, 60 70 ), 61 71 ); 62 72 } ··· 105 115 Future<void> setConstellationUrl(String url) async { 106 116 await database.setSetting(_keyConstellationUrl, url); 107 117 emit(state.copyWith(constellationUrl: url)); 118 + } 119 + 120 + Future<void> setSemanticSearchEnabled(bool value) async { 121 + await database.setSetting(_keySemanticSearchEnabled, value.toString()); 122 + emit(state.copyWith(semanticSearchEnabled: value)); 123 + } 124 + 125 + Future<void> setSearchScope(SearchScope scope) async { 126 + await database.setSetting(_keySearchScope, scope.name); 127 + emit(state.copyWith(searchScope: scope)); 128 + } 129 + 130 + Future<void> setSemanticSearchMaxResults(int value) async { 131 + await database.setSetting(_keySemanticSearchMaxResults, value.toString()); 132 + emit(state.copyWith(semanticSearchMaxResults: value)); 108 133 } 109 134 }
+22
lib/features/settings/bloc/settings_state.dart
··· 1 1 import 'package:equatable/equatable.dart'; 2 2 import 'package:lazurite/core/theme/app_theme.dart'; 3 3 import 'package:lazurite/core/theme/feed_layout.dart'; 4 + import 'package:lazurite/features/search/data/search_scope.dart'; 4 5 5 6 const Object _threadAutoCollapseDepthUnset = Object(); 6 7 ··· 13 14 this.simulateOffline = false, 14 15 this.threadAutoCollapseDepth, 15 16 this.constellationUrl = 'https://constellation.microcosm.blue', 17 + this.semanticSearchEnabled = false, 18 + this.searchScope = SearchScope.both, 19 + this.semanticSearchMaxResults = 20, 16 20 }); 17 21 18 22 final AppThemePalette themePalette; ··· 23 27 final int? threadAutoCollapseDepth; 24 28 final String constellationUrl; 25 29 30 + /// Whether semantic (vector) search is enabled. 31 + final bool semanticSearchEnabled; 32 + 33 + /// Default scope used when opening the search tab. 34 + final SearchScope searchScope; 35 + 36 + /// Maximum number of results returned per search query (10–50). 37 + final int semanticSearchMaxResults; 38 + 26 39 SettingsState copyWith({ 27 40 AppThemePalette? themePalette, 28 41 AppThemeVariant? themeVariant, ··· 31 44 bool? simulateOffline, 32 45 Object? threadAutoCollapseDepth = _threadAutoCollapseDepthUnset, 33 46 String? constellationUrl, 47 + bool? semanticSearchEnabled, 48 + SearchScope? searchScope, 49 + int? semanticSearchMaxResults, 34 50 }) { 35 51 return SettingsState( 36 52 themePalette: themePalette ?? this.themePalette, ··· 42 58 ? this.threadAutoCollapseDepth 43 59 : threadAutoCollapseDepth as int?, 44 60 constellationUrl: constellationUrl ?? this.constellationUrl, 61 + semanticSearchEnabled: semanticSearchEnabled ?? this.semanticSearchEnabled, 62 + searchScope: searchScope ?? this.searchScope, 63 + semanticSearchMaxResults: semanticSearchMaxResults ?? this.semanticSearchMaxResults, 45 64 ); 46 65 } 47 66 ··· 54 73 simulateOffline, 55 74 threadAutoCollapseDepth, 56 75 constellationUrl, 76 + semanticSearchEnabled, 77 + searchScope, 78 + semanticSearchMaxResults, 57 79 ]; 58 80 }
+141
lib/features/settings/presentation/settings_screen.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:flutter/foundation.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 11 13 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 12 14 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 13 15 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 16 + import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 17 + import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 14 18 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 15 19 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 16 20 ··· 70 74 const SizedBox(height: 24), 71 75 _buildSectionHeader(context, 'Moderation'), 72 76 const _ModerationSettingsPreview(), 77 + const SizedBox(height: 24), 78 + _buildSectionHeader(context, 'Search'), 79 + _buildSearchSettings(context), 73 80 const SizedBox(height: 24), 74 81 _buildSectionHeader(context, 'Account'), 75 82 const _AtProtocolConnectionCard(), ··· 267 274 ); 268 275 } 269 276 277 + Widget _buildSearchSettings(BuildContext context) { 278 + return BlocBuilder<SettingsCubit, SettingsState>( 279 + builder: (context, settingsState) { 280 + return Container( 281 + decoration: BoxDecoration( 282 + border: Border( 283 + top: BorderSide(color: Theme.of(context).dividerColor), 284 + bottom: BorderSide(color: Theme.of(context).dividerColor), 285 + ), 286 + color: Theme.of(context).cardColor, 287 + ), 288 + child: Column( 289 + children: [ 290 + _SettingsTile( 291 + icon: Icons.manage_search_outlined, 292 + title: 'Semantic Search', 293 + subtitle: settingsState.semanticSearchEnabled 294 + ? 'Search posts by meaning, not just keywords' 295 + : 'Enable to search posts by meaning', 296 + trailing: Switch.adaptive( 297 + value: settingsState.semanticSearchEnabled, 298 + onChanged: (value) async { 299 + await context.read<SettingsCubit>().setSemanticSearchEnabled(value); 300 + if (value && context.mounted) { 301 + unawaited(context.read<SemanticIndexCubit>().reindex()); 302 + } 303 + }, 304 + ), 305 + ), 306 + if (settingsState.semanticSearchEnabled) ...[ 307 + const Divider(height: 1), 308 + _SettingsDropdownTile<SearchScope>( 309 + title: 'Default Scope', 310 + subtitle: 'Which posts to search by default', 311 + value: settingsState.searchScope, 312 + options: SearchScope.values, 313 + labelBuilder: (scope) => switch (scope) { 314 + SearchScope.both => 'Saved + Liked', 315 + SearchScope.saved => 'Saved only', 316 + SearchScope.liked => 'Liked only', 317 + }, 318 + onChanged: (scope) { 319 + if (scope != null) context.read<SettingsCubit>().setSearchScope(scope); 320 + }, 321 + ), 322 + const Divider(height: 1), 323 + BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 324 + builder: (context, indexState) => _IndexStatusTile(indexState: indexState), 325 + ), 326 + const Divider(height: 1), 327 + _MaxResultsTile( 328 + value: settingsState.semanticSearchMaxResults, 329 + onChanged: (value) { 330 + context.read<SettingsCubit>().setSemanticSearchMaxResults(value); 331 + context.read<SemanticSearchCubit>().setMaxResults(value); 332 + }, 333 + ), 334 + ], 335 + ], 336 + ), 337 + ); 338 + }, 339 + ); 340 + } 341 + 270 342 Widget _buildAdvancedSettings(BuildContext context) { 271 343 return BlocBuilder<SettingsCubit, SettingsState>( 272 344 builder: (context, state) { ··· 634 706 ); 635 707 } 636 708 } 709 + 710 + class _IndexStatusTile extends StatelessWidget { 711 + const _IndexStatusTile({required this.indexState}); 712 + 713 + final SemanticIndexState indexState; 714 + 715 + @override 716 + Widget build(BuildContext context) { 717 + final statusText = indexState.isBackfilling 718 + ? 'Indexing: ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0} posts...' 719 + : '${indexState.indexedCount} posts indexed'; 720 + 721 + return ListTile( 722 + leading: indexState.isBackfilling 723 + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 724 + : const Icon(Icons.data_object_outlined), 725 + title: const Text('Index Status'), 726 + subtitle: Text(statusText), 727 + trailing: indexState.isBackfilling 728 + ? null 729 + : TextButton(onPressed: () => context.read<SemanticIndexCubit>().reindex(), child: const Text('Re-index')), 730 + ); 731 + } 732 + } 733 + 734 + class _MaxResultsTile extends StatelessWidget { 735 + const _MaxResultsTile({required this.value, required this.onChanged}); 736 + 737 + final int value; 738 + final ValueChanged<int> onChanged; 739 + 740 + @override 741 + Widget build(BuildContext context) { 742 + return Column( 743 + crossAxisAlignment: CrossAxisAlignment.start, 744 + children: [ 745 + ListTile( 746 + leading: const Icon(Icons.format_list_numbered_outlined), 747 + title: const Text('Max Results'), 748 + subtitle: const Text('Maximum number of search results'), 749 + trailing: Text( 750 + '$value', 751 + style: Theme.of( 752 + context, 753 + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, fontFamily: 'JetBrains Mono'), 754 + ), 755 + ), 756 + Padding( 757 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 758 + child: Row( 759 + children: [ 760 + const Text('10', style: TextStyle(fontSize: 12)), 761 + Expanded( 762 + child: Slider( 763 + value: value.toDouble(), 764 + min: 10, 765 + max: 50, 766 + divisions: 8, 767 + onChanged: (v) => onChanged(v.round()), 768 + ), 769 + ), 770 + const Text('50', style: TextStyle(fontSize: 12)), 771 + ], 772 + ), 773 + ), 774 + ], 775 + ); 776 + } 777 + }
+2 -4
lib/main.dart
··· 364 364 ), 365 365 ), 366 366 BlocProvider( 367 - create: (context) => LikedPostsSyncCubit( 368 - repository: context.read<LikedPostsRepository>(), 369 - accountDid: accountDid, 370 - ), 367 + create: (context) => 368 + LikedPostsSyncCubit(repository: context.read<LikedPostsRepository>(), accountDid: accountDid), 371 369 ), 372 370 ], 373 371 child: appShell,
+2 -5
test/features/feed/cubit/liked_posts_sync_cubit_test.dart
··· 15 15 mockRepo = MockLikedPostsRepository(); 16 16 }); 17 17 18 - LikedPostsSyncCubit buildCubit() => 19 - LikedPostsSyncCubit(repository: mockRepo, accountDid: _accountDid); 18 + LikedPostsSyncCubit buildCubit() => LikedPostsSyncCubit(repository: mockRepo, accountDid: _accountDid); 20 19 21 20 group('LikedPostsSyncCubit', () { 22 21 group('initial state', () { ··· 52 51 act: (cubit) => cubit.sync(), 53 52 expect: () => [ 54 53 const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing), 55 - predicate<LikedPostsSyncState>( 56 - (s) => s.status == LikedPostsSyncStatus.error && s.errorMessage != null, 57 - ), 54 + predicate<LikedPostsSyncState>((s) => s.status == LikedPostsSyncStatus.error && s.errorMessage != null), 58 55 ], 59 56 ); 60 57
+210
test/features/search/presentation/semantic_search_tab_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 6 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 7 + import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 8 + import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 9 + import 'package:lazurite/features/search/data/semantic_search_repository.dart'; 10 + import 'package:lazurite/features/search/data/semantic_search_result.dart'; 11 + import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 12 + import 'package:mocktail/mocktail.dart'; 13 + 14 + class MockSemanticSearchRepository extends Mock implements SemanticSearchRepository {} 15 + 16 + class MockPostActionRepository extends Mock implements PostActionRepository {} 17 + 18 + class MockSemanticSearchCubit extends MockCubit<SemanticSearchState> implements SemanticSearchCubit {} 19 + 20 + class MockSemanticIndexCubit extends MockCubit<SemanticIndexState> implements SemanticIndexCubit {} 21 + 22 + const _accountDid = 'did:plc:testuser'; 23 + 24 + const _result1 = SemanticSearchResult( 25 + postUri: 'at://did:plc:a/app.bsky.feed.post/1', 26 + score: 82.0, 27 + source: 'saved', 28 + postJson: '{}', 29 + ); 30 + 31 + const _result2 = SemanticSearchResult( 32 + postUri: 'at://did:plc:b/app.bsky.feed.post/2', 33 + score: 48.0, 34 + source: 'liked', 35 + postJson: '{}', 36 + ); 37 + 38 + void main() { 39 + setUpAll(() { 40 + registerFallbackValue(SearchScope.both); 41 + }); 42 + 43 + late MockSemanticSearchCubit searchCubit; 44 + late MockSemanticIndexCubit indexCubit; 45 + late MockPostActionRepository postActionRepository; 46 + 47 + setUp(() { 48 + searchCubit = MockSemanticSearchCubit(); 49 + indexCubit = MockSemanticIndexCubit(); 50 + postActionRepository = MockPostActionRepository(); 51 + 52 + when(() => searchCubit.state).thenReturn(const SemanticSearchState()); 53 + whenListen(searchCubit, const Stream<SemanticSearchState>.empty(), initialState: const SemanticSearchState()); 54 + when(() => indexCubit.state).thenReturn(const SemanticIndexState()); 55 + whenListen(indexCubit, const Stream<SemanticIndexState>.empty(), initialState: const SemanticIndexState()); 56 + }); 57 + 58 + Widget buildSubject() { 59 + return MultiRepositoryProvider( 60 + providers: [ 61 + RepositoryProvider<PostActionRepository>.value(value: postActionRepository), 62 + RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 63 + RepositoryProvider<String>.value(value: _accountDid), 64 + ], 65 + child: MultiBlocProvider( 66 + providers: [ 67 + BlocProvider<SemanticSearchCubit>.value(value: searchCubit), 68 + BlocProvider<SemanticIndexCubit>.value(value: indexCubit), 69 + ], 70 + child: const MaterialApp(home: Scaffold(body: SemanticSearchTab())), 71 + ), 72 + ); 73 + } 74 + 75 + group('SemanticSearchTab', () { 76 + testWidgets('shows empty query state when no query entered', (tester) async { 77 + await tester.pumpWidget(buildSubject()); 78 + expect(find.text('Search by meaning'), findsOneWidget); 79 + expect(find.text('Search your saved and liked posts by meaning, not just keywords'), findsOneWidget); 80 + }); 81 + 82 + testWidgets('shows unavailable state when embedding service is unavailable', (tester) async { 83 + when(() => searchCubit.state).thenReturn(const SemanticSearchState(status: SemanticSearchStatus.unavailable)); 84 + whenListen( 85 + searchCubit, 86 + const Stream<SemanticSearchState>.empty(), 87 + initialState: const SemanticSearchState(status: SemanticSearchStatus.unavailable), 88 + ); 89 + await tester.pumpWidget(buildSubject()); 90 + expect(find.text('Semantic search unavailable'), findsOneWidget); 91 + }); 92 + 93 + testWidgets('shows loading indicator while searching', (tester) async { 94 + when(() => searchCubit.state).thenReturn(const SemanticSearchState(status: SemanticSearchStatus.searching)); 95 + whenListen( 96 + searchCubit, 97 + const Stream<SemanticSearchState>.empty(), 98 + initialState: const SemanticSearchState(status: SemanticSearchStatus.searching), 99 + ); 100 + await tester.pumpWidget(buildSubject()); 101 + expect(find.byType(CircularProgressIndicator), findsAtLeast(1)); 102 + }); 103 + 104 + testWidgets('shows no results state when search yields empty list', (tester) async { 105 + when( 106 + () => searchCubit.state, 107 + ).thenReturn(const SemanticSearchState(status: SemanticSearchStatus.loaded, results: [], query: 'rust')); 108 + whenListen( 109 + searchCubit, 110 + const Stream<SemanticSearchState>.empty(), 111 + initialState: const SemanticSearchState(status: SemanticSearchStatus.loaded, results: [], query: 'rust'), 112 + ); 113 + await tester.pumpWidget(buildSubject()); 114 + expect(find.text('No similar posts found'), findsOneWidget); 115 + }); 116 + 117 + testWidgets('shows error state on search failure', (tester) async { 118 + when( 119 + () => searchCubit.state, 120 + ).thenReturn(const SemanticSearchState(status: SemanticSearchStatus.error, errorMessage: 'Search failed')); 121 + whenListen( 122 + searchCubit, 123 + const Stream<SemanticSearchState>.empty(), 124 + initialState: const SemanticSearchState(status: SemanticSearchStatus.error, errorMessage: 'Search failed'), 125 + ); 126 + await tester.pumpWidget(buildSubject()); 127 + expect(find.text('Search failed'), findsOneWidget); 128 + }); 129 + 130 + testWidgets('renders relevance badges for results', (tester) async { 131 + when(() => searchCubit.state).thenReturn( 132 + const SemanticSearchState(status: SemanticSearchStatus.loaded, results: [_result1, _result2], query: 'flutter'), 133 + ); 134 + whenListen( 135 + searchCubit, 136 + const Stream<SemanticSearchState>.empty(), 137 + initialState: const SemanticSearchState( 138 + status: SemanticSearchStatus.loaded, 139 + results: [_result1, _result2], 140 + query: 'flutter', 141 + ), 142 + ); 143 + await tester.pumpWidget(buildSubject()); 144 + expect(find.text('82%'), findsOneWidget); 145 + expect(find.text('48%'), findsOneWidget); 146 + }); 147 + 148 + testWidgets('renders source tags for results', (tester) async { 149 + when(() => searchCubit.state).thenReturn( 150 + const SemanticSearchState(status: SemanticSearchStatus.loaded, results: [_result1, _result2], query: 'flutter'), 151 + ); 152 + whenListen( 153 + searchCubit, 154 + const Stream<SemanticSearchState>.empty(), 155 + initialState: const SemanticSearchState( 156 + status: SemanticSearchStatus.loaded, 157 + results: [_result1, _result2], 158 + query: 'flutter', 159 + ), 160 + ); 161 + await tester.pumpWidget(buildSubject()); 162 + expect(find.text('Saved'), findsAtLeast(1)); 163 + expect(find.text('Liked'), findsAtLeast(1)); 164 + }); 165 + 166 + testWidgets('renders scope chips', (tester) async { 167 + await tester.pumpWidget(buildSubject()); 168 + expect(find.text('Both'), findsOneWidget); 169 + expect(find.text('Saved'), findsOneWidget); 170 + expect(find.text('Liked'), findsOneWidget); 171 + }); 172 + 173 + testWidgets('tapping scope chip calls setScope', (tester) async { 174 + when(() => searchCubit.setScope(any())).thenAnswer((_) async {}); 175 + await tester.pumpWidget(buildSubject()); 176 + await tester.tap(find.text('Saved')); 177 + await tester.pump(); 178 + verify(() => searchCubit.setScope(SearchScope.saved)).called(1); 179 + }); 180 + 181 + testWidgets('entering a query calls search on the cubit', (tester) async { 182 + when(() => searchCubit.search(any())).thenReturn(null); 183 + await tester.pumpWidget(buildSubject()); 184 + await tester.enterText(find.byType(TextField), 'flutter'); 185 + verify(() => searchCubit.search('flutter')).called(1); 186 + }); 187 + 188 + testWidgets('shows backfill banner when indexing is in progress', (tester) async { 189 + when(() => indexCubit.state).thenReturn( 190 + const SemanticIndexState(status: SemanticIndexStatus.backfilling, backfillCompleted: 42, backfillTotal: 100), 191 + ); 192 + whenListen( 193 + indexCubit, 194 + const Stream<SemanticIndexState>.empty(), 195 + initialState: const SemanticIndexState( 196 + status: SemanticIndexStatus.backfilling, 197 + backfillCompleted: 42, 198 + backfillTotal: 100, 199 + ), 200 + ); 201 + await tester.pumpWidget(buildSubject()); 202 + expect(find.text('Indexing: 42/100 posts...'), findsOneWidget); 203 + }); 204 + 205 + testWidgets('does not show backfill banner when idle', (tester) async { 206 + await tester.pumpWidget(buildSubject()); 207 + expect(find.textContaining('Indexing:'), findsNothing); 208 + }); 209 + }); 210 + }
+229
test/features/settings/presentation/search_settings_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/theme/app_theme.dart'; 6 + import 'package:lazurite/core/theme/feed_layout.dart'; 7 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 8 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 10 + import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 11 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 12 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 13 + import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 14 + import 'package:mocktail/mocktail.dart'; 15 + 16 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 17 + 18 + class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 19 + 20 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 21 + 22 + class MockSemanticIndexCubit extends MockCubit<SemanticIndexState> implements SemanticIndexCubit {} 23 + 24 + class MockSemanticSearchCubit extends MockCubit<SemanticSearchState> implements SemanticSearchCubit {} 25 + 26 + SettingsState _baseSettings({bool semanticSearchEnabled = false, int maxResults = 20}) => SettingsState( 27 + themePalette: AppThemePalette.oxocarbon, 28 + themeVariant: AppThemeVariant.dark, 29 + useSystemTheme: false, 30 + feedLayout: FeedLayout.card, 31 + semanticSearchEnabled: semanticSearchEnabled, 32 + semanticSearchMaxResults: maxResults, 33 + ); 34 + 35 + void main() { 36 + late MockAuthBloc authBloc; 37 + late MockAccountSwitcherCubit accountSwitcherCubit; 38 + late MockSettingsCubit settingsCubit; 39 + late MockSemanticIndexCubit indexCubit; 40 + late MockSemanticSearchCubit searchCubit; 41 + 42 + setUp(() { 43 + authBloc = MockAuthBloc(); 44 + accountSwitcherCubit = MockAccountSwitcherCubit(); 45 + settingsCubit = MockSettingsCubit(); 46 + indexCubit = MockSemanticIndexCubit(); 47 + searchCubit = MockSemanticSearchCubit(); 48 + 49 + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 50 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 51 + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 52 + whenListen( 53 + accountSwitcherCubit, 54 + const Stream<AccountSwitcherState>.empty(), 55 + initialState: const AccountSwitcherState.ready(accounts: []), 56 + ); 57 + 58 + final initialSettings = _baseSettings(); 59 + when(() => settingsCubit.state).thenReturn(initialSettings); 60 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: initialSettings); 61 + 62 + when(() => indexCubit.state).thenReturn(const SemanticIndexState()); 63 + whenListen(indexCubit, const Stream<SemanticIndexState>.empty(), initialState: const SemanticIndexState()); 64 + 65 + when(() => searchCubit.state).thenReturn(const SemanticSearchState()); 66 + whenListen(searchCubit, const Stream<SemanticSearchState>.empty(), initialState: const SemanticSearchState()); 67 + }); 68 + 69 + Widget buildSubject() { 70 + return MultiBlocProvider( 71 + providers: [ 72 + BlocProvider<AuthBloc>.value(value: authBloc), 73 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 74 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 75 + BlocProvider<SemanticIndexCubit>.value(value: indexCubit), 76 + BlocProvider<SemanticSearchCubit>.value(value: searchCubit), 77 + ], 78 + child: const MaterialApp(home: SettingsScreen()), 79 + ); 80 + } 81 + 82 + group('Settings – Search section', () { 83 + testWidgets('shows Search section header', (tester) async { 84 + await tester.pumpWidget(buildSubject()); 85 + await tester.pumpAndSettle(); 86 + await tester.scrollUntilVisible(find.text('SEARCH'), 300); 87 + expect(find.text('SEARCH'), findsOneWidget); 88 + }); 89 + 90 + testWidgets('shows Semantic Search toggle set to off by default', (tester) async { 91 + await tester.pumpWidget(buildSubject()); 92 + await tester.pumpAndSettle(); 93 + await tester.scrollUntilVisible(find.text('Semantic Search'), 300); 94 + final semanticTile = find.ancestor(of: find.text('Semantic Search'), matching: find.byType(ListTile)); 95 + final switchWidget = tester.widget<Switch>(find.descendant(of: semanticTile, matching: find.byType(Switch))); 96 + expect(switchWidget.value, isFalse); 97 + }); 98 + 99 + testWidgets('toggling Semantic Search on calls setSemanticSearchEnabled and reindex', (tester) async { 100 + await tester.binding.setSurfaceSize(const Size(800, 2400)); 101 + addTearDown(() => tester.binding.setSurfaceSize(null)); 102 + 103 + when(() => settingsCubit.setSemanticSearchEnabled(any())).thenAnswer((_) async {}); 104 + when(() => indexCubit.reindex()).thenAnswer((_) async {}); 105 + 106 + await tester.pumpWidget(buildSubject()); 107 + await tester.pumpAndSettle(); 108 + 109 + final semanticTile = find.ancestor(of: find.text('Semantic Search'), matching: find.byType(ListTile)); 110 + await tester.tap(find.descendant(of: semanticTile, matching: find.byType(Switch))); 111 + await tester.pumpAndSettle(); 112 + 113 + verify(() => settingsCubit.setSemanticSearchEnabled(true)).called(1); 114 + verify(() => indexCubit.reindex()).called(1); 115 + }); 116 + 117 + testWidgets('shows additional settings when semantic search is enabled', (tester) async { 118 + final enabledSettings = _baseSettings(semanticSearchEnabled: true); 119 + when(() => settingsCubit.state).thenReturn(enabledSettings); 120 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 121 + 122 + await tester.pumpWidget(buildSubject()); 123 + await tester.pumpAndSettle(); 124 + await tester.scrollUntilVisible(find.text('Default Scope'), 300); 125 + 126 + expect(find.text('Default Scope'), findsOneWidget); 127 + expect(find.text('Index Status'), findsOneWidget); 128 + expect(find.text('Max Results'), findsOneWidget); 129 + }); 130 + 131 + testWidgets('hides advanced settings when semantic search is disabled', (tester) async { 132 + await tester.pumpWidget(buildSubject()); 133 + await tester.pumpAndSettle(); 134 + 135 + expect(find.text('Default Scope'), findsNothing); 136 + expect(find.text('Index Status'), findsNothing); 137 + expect(find.text('Max Results'), findsNothing); 138 + }); 139 + 140 + testWidgets('shows indexed post count in Index Status tile', (tester) async { 141 + when(() => indexCubit.state).thenReturn(const SemanticIndexState(indexedCount: 73)); 142 + whenListen( 143 + indexCubit, 144 + const Stream<SemanticIndexState>.empty(), 145 + initialState: const SemanticIndexState(indexedCount: 73), 146 + ); 147 + 148 + final enabledSettings = _baseSettings(semanticSearchEnabled: true); 149 + when(() => settingsCubit.state).thenReturn(enabledSettings); 150 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 151 + 152 + await tester.pumpWidget(buildSubject()); 153 + await tester.pumpAndSettle(); 154 + await tester.scrollUntilVisible(find.text('73 posts indexed'), 300); 155 + 156 + expect(find.text('73 posts indexed'), findsOneWidget); 157 + }); 158 + 159 + testWidgets('shows backfill progress in Index Status tile while indexing', (tester) async { 160 + when(() => indexCubit.state).thenReturn( 161 + const SemanticIndexState(status: SemanticIndexStatus.backfilling, backfillCompleted: 30, backfillTotal: 100), 162 + ); 163 + whenListen( 164 + indexCubit, 165 + const Stream<SemanticIndexState>.empty(), 166 + initialState: const SemanticIndexState( 167 + status: SemanticIndexStatus.backfilling, 168 + backfillCompleted: 30, 169 + backfillTotal: 100, 170 + ), 171 + ); 172 + 173 + final enabledSettings = _baseSettings(semanticSearchEnabled: true); 174 + when(() => settingsCubit.state).thenReturn(enabledSettings); 175 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 176 + 177 + await tester.pumpWidget(buildSubject()); 178 + await tester.pumpAndSettle(); 179 + await tester.scrollUntilVisible(find.text('Indexing: 30/100 posts...'), 300); 180 + 181 + expect(find.text('Indexing: 30/100 posts...'), findsOneWidget); 182 + }); 183 + 184 + testWidgets('Re-index button triggers reindex', (tester) async { 185 + await tester.binding.setSurfaceSize(const Size(800, 2400)); 186 + addTearDown(() => tester.binding.setSurfaceSize(null)); 187 + 188 + when(() => indexCubit.reindex()).thenAnswer((_) async {}); 189 + 190 + final enabledSettings = _baseSettings(semanticSearchEnabled: true); 191 + when(() => settingsCubit.state).thenReturn(enabledSettings); 192 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 193 + 194 + await tester.pumpWidget(buildSubject()); 195 + await tester.pumpAndSettle(); 196 + 197 + await tester.tap(find.text('Re-index')); 198 + await tester.pump(); 199 + 200 + verify(() => indexCubit.reindex()).called(1); 201 + }); 202 + 203 + testWidgets('max results slider reflects current settings value', (tester) async { 204 + final enabledSettings = _baseSettings(semanticSearchEnabled: true, maxResults: 30); 205 + when(() => settingsCubit.state).thenReturn(enabledSettings); 206 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 207 + 208 + await tester.pumpWidget(buildSubject()); 209 + await tester.pumpAndSettle(); 210 + await tester.scrollUntilVisible(find.text('30'), 300); 211 + 212 + expect(find.text('30'), findsOneWidget); 213 + final slider = tester.widget<Slider>(find.byType(Slider)); 214 + expect(slider.value, 30.0); 215 + }); 216 + 217 + testWidgets('default scope dropdown shows current scope', (tester) async { 218 + final enabledSettings = _baseSettings(semanticSearchEnabled: true).copyWith(searchScope: SearchScope.saved); 219 + when(() => settingsCubit.state).thenReturn(enabledSettings); 220 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 221 + 222 + await tester.pumpWidget(buildSubject()); 223 + await tester.pumpAndSettle(); 224 + await tester.scrollUntilVisible(find.text('Saved only'), 300); 225 + 226 + expect(find.text('Saved only'), findsOneWidget); 227 + }); 228 + }); 229 + }