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: expand and move semantic search settings to saved posts tab

+441 -389
+35 -9
lib/features/search/cubit/semantic_search_cubit.dart
··· 67 67 embeddingService.isAvailable 68 68 ? const SemanticSearchState() 69 69 : const SemanticSearchState(status: SemanticSearchStatus.unavailable), 70 - ); 70 + ) { 71 + if (!embeddingService.isAvailable) { 72 + unawaited(_recoverAvailability()); 73 + } 74 + } 71 75 72 76 final SemanticSearchRepository _repository; 73 77 final EmbeddingService _embeddingService; ··· 82 86 /// Queue a search for [query], debounced by [_debounceDuration]. 83 87 /// 84 88 /// An empty query clears results immediately without waiting for the debounce. 85 - /// A no-op when the embedding service is unavailable. 89 + /// Attempts to recover service availability before searching. 86 90 void search(String query) { 87 - if (!_embeddingService.isAvailable) { 88 - emit(const SemanticSearchState(status: SemanticSearchStatus.unavailable)); 89 - return; 90 - } 91 91 _debounce?.cancel(); 92 92 if (query.trim().isEmpty) { 93 93 emit(SemanticSearchState(scope: state.scope)); 94 94 return; 95 95 } 96 - _debounce = Timer(_debounceDuration, () => unawaited(_doSearch(query))); 96 + _debounce = Timer(_debounceDuration, () => unawaited(_searchWithAvailability(query))); 97 97 } 98 98 99 99 /// Change the search scope and immediately re-run the current query. 100 100 Future<void> setScope(SearchScope scope) async { 101 101 if (state.scope == scope) return; 102 102 emit(state.copyWith(scope: scope)); 103 - if (state.query.trim().isNotEmpty && _embeddingService.isAvailable) { 103 + if (state.query.trim().isNotEmpty) { 104 104 _debounce?.cancel(); 105 - await _doSearch(state.query); 105 + if (await _ensureAvailable()) { 106 + await _doSearch(state.query); 107 + } else if (!isClosed) { 108 + emit(state.copyWith(status: SemanticSearchStatus.unavailable)); 109 + } 106 110 } 107 111 } 108 112 ··· 110 114 void clearResults() { 111 115 _debounce?.cancel(); 112 116 emit(SemanticSearchState(scope: state.scope)); 117 + } 118 + 119 + Future<void> _recoverAvailability() async { 120 + if (await _ensureAvailable()) { 121 + if (!isClosed && state.status == SemanticSearchStatus.unavailable) { 122 + emit(SemanticSearchState(scope: state.scope)); 123 + } 124 + } 125 + } 126 + 127 + Future<bool> _ensureAvailable() async { 128 + if (_embeddingService.isAvailable) return true; 129 + await _embeddingService.initialize(); 130 + return _embeddingService.isAvailable; 131 + } 132 + 133 + Future<void> _searchWithAvailability(String query) async { 134 + if (await _ensureAvailable()) { 135 + await _doSearch(query); 136 + } else if (!isClosed) { 137 + emit(state.copyWith(status: SemanticSearchStatus.unavailable)); 138 + } 113 139 } 114 140 115 141 Future<void> _doSearch(String query) async {
+303 -116
lib/features/search/presentation/semantic_search_tab.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 - import 'package:lazurite/core/theme/theme_extensions.dart'; 3 3 4 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 5 5 import 'package:flutter/material.dart'; 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:lazurite/core/logging/app_logger.dart'; 8 + import 'package:lazurite/core/theme/theme_extensions.dart'; 8 9 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 9 10 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 10 11 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 11 12 import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 12 13 import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 13 14 import 'package:lazurite/features/search/data/semantic_search_result.dart'; 15 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 16 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 14 17 15 18 /// The "Search" tab inside the saved posts screen. 16 19 /// 17 - /// Renders a search input, scope chips, and a results list backed by 18 - /// [SemanticSearchCubit]. Requires [SemanticSearchCubit], 19 - /// [SemanticIndexCubit], [PostActionRepository], [PostActionCache], and 20 - /// a [String] account DID to be available in the widget tree. 20 + /// Renders a search input, scope chips, and a results list backed by [SemanticSearchCubit]. 21 + /// Requires [SemanticSearchCubit], [SemanticIndexCubit], [PostActionRepository], 22 + /// [PostActionCache], and a [String] account DID to be available in the widget tree. 21 23 class SemanticSearchTab extends StatefulWidget { 22 24 const SemanticSearchTab({super.key}); 23 25 ··· 29 31 final TextEditingController _controller = TextEditingController(); 30 32 31 33 @override 34 + void initState() { 35 + super.initState(); 36 + WidgetsBinding.instance.addPostFrameCallback((_) { 37 + if (mounted) { 38 + final settings = context.read<SettingsCubit>().state; 39 + context.read<SemanticSearchCubit>().setMaxResults(settings.semanticSearchMaxResults); 40 + unawaited(context.read<SemanticSearchCubit>().setScope(settings.searchScope)); 41 + context.read<SemanticIndexCubit>().loadCount(); 42 + } 43 + }); 44 + } 45 + 46 + @override 32 47 void dispose() { 33 48 _controller.dispose(); 34 49 super.dispose(); ··· 42 57 return const _UnavailableView(); 43 58 } 44 59 45 - return Column( 46 - children: [ 47 - BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 48 - builder: (context, indexState) { 49 - if (!indexState.isBackfilling) return const SizedBox.shrink(); 50 - return _BackfillBanner(indexState: indexState); 51 - }, 60 + return BlocBuilder<SettingsCubit, SettingsState>( 61 + builder: (context, settingsState) { 62 + return Column( 63 + children: [ 64 + BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 65 + builder: (context, indexState) { 66 + return _IndexControls(indexState: indexState); 67 + }, 68 + ), 69 + if (!settingsState.semanticSearchEnabled) ...[ 70 + const Divider(height: 1), 71 + const Expanded(child: _DisabledView()), 72 + ] else ...[ 73 + _SearchBar( 74 + controller: _controller, 75 + onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 76 + onClear: () { 77 + _controller.clear(); 78 + context.read<SemanticSearchCubit>().clearResults(); 79 + }, 80 + ), 81 + _ScopeChips( 82 + selected: state.scope, 83 + onSelected: (scope) async { 84 + await context.read<SemanticSearchCubit>().setScope(scope); 85 + if (context.mounted) { 86 + unawaited(context.read<SettingsCubit>().setSearchScope(scope)); 87 + } 88 + }, 89 + ), 90 + const Divider(height: 1), 91 + Expanded(child: _ResultsView(state: state)), 92 + ], 93 + ], 94 + ); 95 + }, 96 + ); 97 + }, 98 + ); 99 + } 100 + } 101 + 102 + class _IndexControls extends StatelessWidget { 103 + const _IndexControls({required this.indexState}); 104 + 105 + final SemanticIndexState indexState; 106 + 107 + Future<void> _onMenuSelected(BuildContext context, _IndexMenuAction action) async { 108 + final cubit = context.read<SemanticIndexCubit>(); 109 + switch (action) { 110 + case _IndexMenuAction.semanticSettings: 111 + await showModalBottomSheet<void>( 112 + context: context, 113 + showDragHandle: true, 114 + isScrollControlled: true, 115 + builder: (sheetContext) => const _SemanticSettingsSheet(), 116 + ); 117 + break; 118 + case _IndexMenuAction.refreshCount: 119 + cubit.loadCount(); 120 + break; 121 + case _IndexMenuAction.reindex: 122 + unawaited(cubit.reindex()); 123 + break; 124 + } 125 + } 126 + 127 + @override 128 + Widget build(BuildContext context) { 129 + final scheme = context.colorScheme; 130 + final completed = indexState.backfillCompleted ?? 0; 131 + final total = indexState.backfillTotal ?? 0; 132 + final progress = total > 0 ? completed / total : 0.0; 133 + return Container( 134 + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), 135 + color: scheme.surface, 136 + child: Column( 137 + children: [ 138 + Row( 139 + children: [ 140 + Expanded( 141 + child: Text( 142 + indexState.isBackfilling 143 + ? 'Indexing: ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0} posts...' 144 + : '${indexState.indexedCount} posts indexed', 145 + style: context.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant), 146 + ), 147 + ), 148 + PopupMenuButton<_IndexMenuAction>( 149 + tooltip: 'Search index actions', 150 + icon: const Icon(Icons.more_vert), 151 + onSelected: (action) => unawaited(_onMenuSelected(context, action)), 152 + itemBuilder: (context) => [ 153 + const PopupMenuItem<_IndexMenuAction>( 154 + value: _IndexMenuAction.semanticSettings, 155 + child: Text('Semantic settings'), 156 + ), 157 + const PopupMenuItem<_IndexMenuAction>( 158 + value: _IndexMenuAction.refreshCount, 159 + child: Text('Refresh indexed count'), 160 + ), 161 + PopupMenuItem<_IndexMenuAction>( 162 + value: _IndexMenuAction.reindex, 163 + enabled: !indexState.isBackfilling, 164 + child: const Text('Re-index posts'), 165 + ), 166 + ], 167 + ), 168 + ], 169 + ), 170 + if (indexState.isBackfilling) ...[ 171 + const SizedBox(height: 4), 172 + LinearProgressIndicator(value: progress > 0 ? progress : null), 173 + ], 174 + if (indexState.status == SemanticIndexStatus.error && indexState.errorMessage != null) ...[ 175 + const SizedBox(height: 6), 176 + Align( 177 + alignment: Alignment.centerLeft, 178 + child: Text(indexState.errorMessage!, style: context.textTheme.bodySmall?.copyWith(color: scheme.error)), 52 179 ), 53 - _SearchBar( 54 - controller: _controller, 55 - onChanged: (query) => context.read<SemanticSearchCubit>().search(query), 56 - onClear: () { 57 - _controller.clear(); 58 - context.read<SemanticSearchCubit>().clearResults(); 59 - }, 180 + ], 181 + ], 182 + ), 183 + ); 184 + } 185 + } 186 + 187 + enum _IndexMenuAction { semanticSettings, refreshCount, reindex } 188 + 189 + class _SemanticSettingsSheet extends StatelessWidget { 190 + const _SemanticSettingsSheet(); 191 + 192 + @override 193 + Widget build(BuildContext context) { 194 + return SafeArea( 195 + top: false, 196 + child: BlocBuilder<SettingsCubit, SettingsState>( 197 + builder: (context, settingsState) { 198 + return SingleChildScrollView( 199 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), 200 + child: Column( 201 + mainAxisSize: MainAxisSize.min, 202 + crossAxisAlignment: CrossAxisAlignment.start, 203 + children: [ 204 + Text('Semantic settings', style: context.textTheme.titleLarge), 205 + const SizedBox(height: 12), 206 + SwitchListTile.adaptive( 207 + value: settingsState.semanticSearchEnabled, 208 + onChanged: (value) => unawaited(context.read<SettingsCubit>().setSemanticSearchEnabled(value)), 209 + title: const Text('Enable semantic search'), 210 + subtitle: const Text('Search this account\'s saved and liked posts by meaning'), 211 + contentPadding: EdgeInsets.zero, 212 + ), 213 + if (settingsState.semanticSearchEnabled) ...[ 214 + const SizedBox(height: 8), 215 + Text('Default scope', style: context.textTheme.titleSmall), 216 + const SizedBox(height: 8), 217 + DropdownButtonFormField<SearchScope>( 218 + initialValue: settingsState.searchScope, 219 + decoration: const InputDecoration(border: OutlineInputBorder()), 220 + items: const [ 221 + DropdownMenuItem(value: SearchScope.both, child: Text('Saved + Liked')), 222 + DropdownMenuItem(value: SearchScope.saved, child: Text('Saved only')), 223 + DropdownMenuItem(value: SearchScope.liked, child: Text('Liked only')), 224 + ], 225 + onChanged: (scope) async { 226 + if (scope == null) return; 227 + await context.read<SettingsCubit>().setSearchScope(scope); 228 + if (context.mounted) { 229 + await context.read<SemanticSearchCubit>().setScope(scope); 230 + } 231 + }, 232 + ), 233 + const SizedBox(height: 16), 234 + Row( 235 + children: [ 236 + Text('Max results', style: context.textTheme.titleSmall), 237 + const Spacer(), 238 + Text( 239 + '${settingsState.semanticSearchMaxResults}', 240 + style: context.textTheme.titleSmall?.copyWith(fontFamily: 'JetBrains Mono'), 241 + ), 242 + ], 243 + ), 244 + Slider( 245 + value: settingsState.semanticSearchMaxResults.toDouble(), 246 + min: 10, 247 + max: 50, 248 + divisions: 8, 249 + onChanged: (v) { 250 + final value = v.round(); 251 + context.read<SettingsCubit>().setSemanticSearchMaxResults(value); 252 + context.read<SemanticSearchCubit>().setMaxResults(value); 253 + }, 254 + ), 255 + ], 256 + ], 60 257 ), 61 - _ScopeChips(selected: state.scope, onSelected: context.read<SemanticSearchCubit>().setScope), 62 - const Divider(height: 1), 63 - Expanded(child: _ResultsView(state: state)), 64 - ], 65 - ); 66 - }, 258 + ); 259 + }, 260 + ), 67 261 ); 68 262 } 69 263 } ··· 172 366 final SemanticSearchState state; 173 367 174 368 @override 175 - Widget build(BuildContext context) { 176 - return switch (state.status) { 177 - SemanticSearchStatus.initial => const _EmptyQueryView(), 178 - SemanticSearchStatus.searching => const Center(child: CircularProgressIndicator()), 179 - SemanticSearchStatus.loaded when state.results.isEmpty => const _NoResultsView(), 180 - SemanticSearchStatus.loaded => _ResultsList(results: state.results), 181 - SemanticSearchStatus.error => _ErrorView(message: state.errorMessage), 182 - SemanticSearchStatus.unavailable => const _UnavailableView(), 183 - }; 184 - } 369 + Widget build(BuildContext context) => switch (state.status) { 370 + SemanticSearchStatus.initial => const _EmptyQueryView(), 371 + SemanticSearchStatus.searching => const Center(child: CircularProgressIndicator()), 372 + SemanticSearchStatus.loaded when state.results.isEmpty => const _NoResultsView(), 373 + SemanticSearchStatus.loaded => _ResultsList(results: state.results), 374 + SemanticSearchStatus.error => _ErrorView(message: state.errorMessage), 375 + SemanticSearchStatus.unavailable => const _UnavailableView(), 376 + }; 185 377 } 186 378 187 379 class _ResultsList extends StatelessWidget { ··· 190 382 final List<SemanticSearchResult> results; 191 383 192 384 @override 193 - Widget build(BuildContext context) { 194 - return ListView.builder( 195 - itemCount: results.length, 196 - itemBuilder: (context, index) { 197 - final result = results[index]; 198 - return _ResultCard(result: result); 199 - }, 200 - ); 201 - } 385 + Widget build(BuildContext context) => ListView.builder( 386 + itemCount: results.length, 387 + itemBuilder: (context, index) { 388 + final result = results[index]; 389 + return _ResultCard(result: result); 390 + }, 391 + ); 202 392 } 203 393 204 394 class _ResultCard extends StatelessWidget { ··· 307 497 final String postUri; 308 498 309 499 @override 310 - Widget build(BuildContext context) { 311 - return ListTile( 312 - leading: const Icon(Icons.article_outlined), 313 - title: const Text('Post'), 314 - subtitle: Text(postUri, maxLines: 1, overflow: TextOverflow.ellipsis), 315 - ); 316 - } 500 + Widget build(BuildContext context) => ListTile( 501 + leading: const Icon(Icons.article_outlined), 502 + title: const Text('Post'), 503 + subtitle: Text(postUri, maxLines: 1, overflow: TextOverflow.ellipsis), 504 + ); 317 505 } 318 506 319 507 class _EmptyQueryView extends StatelessWidget { ··· 323 511 Widget build(BuildContext context) { 324 512 final scheme = context.colorScheme; 325 513 return Center( 326 - child: Padding( 514 + child: SingleChildScrollView( 327 515 padding: const EdgeInsets.all(32), 328 516 child: Column( 517 + mainAxisSize: MainAxisSize.min, 329 518 mainAxisAlignment: MainAxisAlignment.center, 330 519 children: [ 331 520 Icon(Icons.travel_explore_outlined, size: 64, color: scheme.outline), ··· 351 540 Widget build(BuildContext context) { 352 541 final scheme = context.colorScheme; 353 542 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: 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: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 367 - ), 368 - ], 543 + child: SingleChildScrollView( 544 + padding: const EdgeInsets.all(24), 545 + child: Column( 546 + mainAxisSize: MainAxisSize.min, 547 + mainAxisAlignment: MainAxisAlignment.center, 548 + children: [ 549 + Icon(Icons.search_off_outlined, size: 64, color: scheme.outline), 550 + const SizedBox(height: 16), 551 + Text( 552 + 'No similar posts found', 553 + style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 554 + ), 555 + const SizedBox(height: 8), 556 + Text( 557 + 'Try different keywords or a broader scope', 558 + style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 559 + ), 560 + ], 561 + ), 369 562 ), 370 563 ); 371 564 } 372 565 } 373 566 374 - class _ErrorView extends StatelessWidget { 375 - const _ErrorView({required this.message}); 376 - 377 - final String? message; 567 + class _DisabledView extends StatelessWidget { 568 + const _DisabledView(); 378 569 379 570 @override 380 571 Widget build(BuildContext context) { 572 + final scheme = context.colorScheme; 381 573 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 - ], 574 + child: SingleChildScrollView( 575 + padding: const EdgeInsets.all(32), 576 + child: Column( 577 + mainAxisSize: MainAxisSize.min, 578 + mainAxisAlignment: MainAxisAlignment.center, 579 + children: [ 580 + Icon(Icons.search_off_outlined, size: 64, color: scheme.outline), 581 + const SizedBox(height: 16), 582 + Text( 583 + 'Semantic search is off', 584 + style: context.textTheme.headlineSmall?.copyWith(color: scheme.onSurfaceVariant), 585 + ), 586 + const SizedBox(height: 8), 587 + Text( 588 + 'Turn it on above to search your saved and liked posts by meaning.', 589 + textAlign: TextAlign.center, 590 + style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant), 591 + ), 592 + ], 593 + ), 391 594 ), 392 595 ); 393 596 } 394 597 } 395 598 599 + class _ErrorView extends StatelessWidget { 600 + const _ErrorView({required this.message}); 601 + 602 + final String? message; 603 + 604 + @override 605 + Widget build(BuildContext context) => Center( 606 + child: Column( 607 + mainAxisAlignment: MainAxisAlignment.center, 608 + children: [ 609 + const Icon(Icons.error_outline, size: 48, color: Colors.grey), 610 + const SizedBox(height: 16), 611 + Text(message ?? 'Search failed. Please try again.'), 612 + const SizedBox(height: 16), 613 + FilledButton(onPressed: () => context.read<SemanticSearchCubit>().clearResults(), child: const Text('Clear')), 614 + ], 615 + ), 616 + ); 617 + } 618 + 396 619 class _UnavailableView extends StatelessWidget { 397 620 const _UnavailableView(); 398 621 ··· 423 646 ); 424 647 } 425 648 } 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 = 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: 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 - }
+4 -113
lib/features/settings/presentation/settings_screen.dart
··· 15 15 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 16 16 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 17 17 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 18 - import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 19 - import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 20 18 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 21 19 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 22 20 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; ··· 346 344 ), 347 345 ), 348 346 const Divider(height: 1), 349 - _SettingsTile( 347 + const _SettingsTile( 350 348 icon: Icons.manage_search_outlined, 351 349 title: 'Semantic Search', 352 - subtitle: settingsState.semanticSearchEnabled 353 - ? 'Search your liked & saved posts by meaning, not just keywords' 354 - : 'Enable this to search your liked & saved posts by meaning', 355 - trailing: Switch.adaptive( 356 - value: settingsState.semanticSearchEnabled, 357 - onChanged: (value) async { 358 - await context.read<SettingsCubit>().setSemanticSearchEnabled(value); 359 - if (value && context.mounted) { 360 - unawaited(context.read<SemanticIndexCubit>().reindex()); 361 - } 362 - }, 363 - ), 350 + subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', 364 351 ), 365 - if (settingsState.semanticSearchEnabled) ...[ 366 - const Divider(height: 1), 367 - _SettingsDropdownTile<SearchScope>( 368 - title: 'Default Scope', 369 - subtitle: 'Which posts to search by default', 370 - value: settingsState.searchScope, 371 - options: SearchScope.values, 372 - labelBuilder: (scope) => switch (scope) { 373 - SearchScope.both => 'Saved + Liked', 374 - SearchScope.saved => 'Saved only', 375 - SearchScope.liked => 'Liked only', 376 - }, 377 - onChanged: (scope) { 378 - if (scope != null) context.read<SettingsCubit>().setSearchScope(scope); 379 - }, 380 - ), 381 - const Divider(height: 1), 382 - BlocBuilder<SemanticIndexCubit, SemanticIndexState>( 383 - builder: (context, indexState) => _IndexStatusTile(indexState: indexState), 384 - ), 385 - const Divider(height: 1), 386 - _MaxResultsTile( 387 - value: settingsState.semanticSearchMaxResults, 388 - onChanged: (value) { 389 - context.read<SettingsCubit>().setSemanticSearchMaxResults(value); 390 - context.read<SemanticSearchCubit>().setMaxResults(value); 391 - }, 392 - ), 393 - ], 394 352 ], 395 353 ), 396 354 ); ··· 511 469 _ConnectionDetailRow(label: 'Last Error', value: state.appViewLastError ?? 'None'), 512 470 const Divider(height: 1), 513 471 _SettingsTile( 514 - icon: Icons.refresh_outlined, 472 + icon: Icons.medical_information_outlined, 515 473 title: 'Refresh Provider Health', 516 474 subtitle: 'Probe public AppView endpoints now', 517 475 trailing: state.appViewHealthRefreshing 518 476 ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) 519 - : null, 477 + : const Icon(Icons.refresh_outlined), 520 478 onTap: state.appViewHealthRefreshing 521 479 ? null 522 480 : () { ··· 869 827 ); 870 828 } 871 829 } 872 - 873 - class _IndexStatusTile extends StatelessWidget { 874 - const _IndexStatusTile({required this.indexState}); 875 - 876 - final SemanticIndexState indexState; 877 - 878 - @override 879 - Widget build(BuildContext context) { 880 - final statusText = indexState.isBackfilling 881 - ? 'Indexing: ${indexState.backfillCompleted ?? 0}/${indexState.backfillTotal ?? 0} posts...' 882 - : '${indexState.indexedCount} posts indexed'; 883 - 884 - return ListTile( 885 - leading: indexState.isBackfilling 886 - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) 887 - : const Icon(Icons.data_object_outlined), 888 - title: const Text('Index Status'), 889 - subtitle: Text(statusText), 890 - trailing: indexState.isBackfilling 891 - ? null 892 - : TextButton(onPressed: () => context.read<SemanticIndexCubit>().reindex(), child: const Text('Re-index')), 893 - ); 894 - } 895 - } 896 - 897 - class _MaxResultsTile extends StatelessWidget { 898 - const _MaxResultsTile({required this.value, required this.onChanged}); 899 - 900 - final int value; 901 - final ValueChanged<int> onChanged; 902 - 903 - @override 904 - Widget build(BuildContext context) { 905 - return Column( 906 - crossAxisAlignment: CrossAxisAlignment.start, 907 - children: [ 908 - ListTile( 909 - leading: const Icon(Icons.format_list_numbered_outlined), 910 - title: const Text('Max Results'), 911 - subtitle: const Text('Maximum number of search results'), 912 - trailing: Text( 913 - '$value', 914 - style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, fontFamily: 'JetBrains Mono'), 915 - ), 916 - ), 917 - Padding( 918 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 919 - child: Row( 920 - children: [ 921 - const Text('10', style: TextStyle(fontSize: 12)), 922 - Expanded( 923 - child: Slider( 924 - value: value.toDouble(), 925 - min: 10, 926 - max: 50, 927 - divisions: 8, 928 - onChanged: (v) => onChanged(v.round()), 929 - ), 930 - ), 931 - const Text('50', style: TextStyle(fontSize: 12)), 932 - ], 933 - ), 934 - ), 935 - ], 936 - ); 937 - } 938 - }
+19 -3
test/features/search/cubit/semantic_search_cubit_test.dart
··· 69 69 ); 70 70 expect(cubit.state.status, SemanticSearchStatus.unavailable); 71 71 }); 72 + 73 + test('recovers from startup race once embedding service initializes', () async { 74 + final lateInitService = EmbeddingService.forTesting((_) async => Float32List.fromList(List.filled(384, 0.2))); 75 + final cubit = SemanticSearchCubit( 76 + repository: mockRepo, 77 + embeddingService: lateInitService, 78 + accountDid: _accountDid, 79 + ); 80 + 81 + expect(cubit.state.status, SemanticSearchStatus.unavailable); 82 + await Future<void>.delayed(Duration.zero); 83 + expect(cubit.state.status, SemanticSearchStatus.initial); 84 + }); 72 85 }); 73 86 74 87 group('search', () { ··· 222 235 ); 223 236 224 237 blocTest<SemanticSearchCubit, SemanticSearchState>( 225 - 'emits unavailable when service is not available', 238 + 'keeps unavailable state when service is not available', 226 239 build: () => 227 240 SemanticSearchCubit(repository: mockRepo, embeddingService: _unavailableService(), accountDid: _accountDid), 228 241 act: (cubit) => cubit.search('flutter'), 229 - expect: () => [predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.unavailable)], 230 - verify: (_) => verifyNever(() => mockRepo.search(any(), any())), 242 + expect: () => [], 243 + verify: (cubit) { 244 + expect(cubit.state.status, SemanticSearchStatus.unavailable); 245 + verifyNever(() => mockRepo.search(any(), any())); 246 + }, 231 247 ); 232 248 }); 233 249
+75 -2
test/features/search/presentation/semantic_search_tab_test.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/theme/app_theme.dart'; 5 6 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 6 7 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 7 8 import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; ··· 9 10 import 'package:lazurite/features/search/data/semantic_search_repository.dart'; 10 11 import 'package:lazurite/features/search/data/semantic_search_result.dart'; 11 12 import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 13 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 14 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 12 15 import 'package:mocktail/mocktail.dart'; 13 16 14 17 class MockSemanticSearchRepository extends Mock implements SemanticSearchRepository {} ··· 18 21 class MockSemanticSearchCubit extends MockCubit<SemanticSearchState> implements SemanticSearchCubit {} 19 22 20 23 class MockSemanticIndexCubit extends MockCubit<SemanticIndexState> implements SemanticIndexCubit {} 24 + 25 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 21 26 22 27 const _accountDid = 'did:plc:testuser'; 23 28 ··· 42 47 43 48 late MockSemanticSearchCubit searchCubit; 44 49 late MockSemanticIndexCubit indexCubit; 50 + late MockSettingsCubit settingsCubit; 45 51 late MockPostActionRepository postActionRepository; 46 52 47 53 setUp(() { 48 54 searchCubit = MockSemanticSearchCubit(); 49 55 indexCubit = MockSemanticIndexCubit(); 56 + settingsCubit = MockSettingsCubit(); 50 57 postActionRepository = MockPostActionRepository(); 51 58 52 59 when(() => searchCubit.state).thenReturn(const SemanticSearchState()); 53 60 whenListen(searchCubit, const Stream<SemanticSearchState>.empty(), initialState: const SemanticSearchState()); 61 + when(() => searchCubit.setScope(any())).thenAnswer((_) async {}); 62 + when(() => searchCubit.setMaxResults(any())).thenReturn(null); 54 63 when(() => indexCubit.state).thenReturn(const SemanticIndexState()); 55 64 whenListen(indexCubit, const Stream<SemanticIndexState>.empty(), initialState: const SemanticIndexState()); 65 + when(() => indexCubit.loadCount()).thenReturn(null); 66 + when(() => indexCubit.reindex()).thenAnswer((_) async {}); 67 + when(() => settingsCubit.state).thenReturn( 68 + const SettingsState( 69 + themePalette: AppThemePalette.oxocarbon, 70 + themeVariant: AppThemeVariant.dark, 71 + useSystemTheme: false, 72 + semanticSearchEnabled: true, 73 + ), 74 + ); 75 + whenListen( 76 + settingsCubit, 77 + const Stream<SettingsState>.empty(), 78 + initialState: const SettingsState( 79 + themePalette: AppThemePalette.oxocarbon, 80 + themeVariant: AppThemeVariant.dark, 81 + useSystemTheme: false, 82 + semanticSearchEnabled: true, 83 + ), 84 + ); 85 + when(() => settingsCubit.setSemanticSearchEnabled(any())).thenAnswer((_) async {}); 86 + when(() => settingsCubit.setSemanticSearchMaxResults(any())).thenAnswer((_) async {}); 87 + when(() => settingsCubit.setSearchScope(any())).thenAnswer((_) async {}); 56 88 }); 57 89 58 90 Widget buildSubject() { ··· 66 98 providers: [ 67 99 BlocProvider<SemanticSearchCubit>.value(value: searchCubit), 68 100 BlocProvider<SemanticIndexCubit>.value(value: indexCubit), 101 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 69 102 ], 70 103 child: const MaterialApp(home: Scaffold(body: SemanticSearchTab())), 71 104 ), ··· 176 209 await tester.tap(find.text('Saved')); 177 210 await tester.pump(); 178 211 verify(() => searchCubit.setScope(SearchScope.saved)).called(1); 212 + verify(() => settingsCubit.setSearchScope(SearchScope.saved)).called(1); 179 213 }); 180 214 181 215 testWidgets('entering a query calls search on the cubit', (tester) async { ··· 202 236 expect(find.text('Indexing: 42/100 posts...'), findsOneWidget); 203 237 }); 204 238 205 - testWidgets('does not show backfill banner when idle', (tester) async { 239 + testWidgets('shows indexed count header when idle', (tester) async { 240 + await tester.pumpWidget(buildSubject()); 241 + expect(find.text('0 posts indexed'), findsOneWidget); 242 + }); 243 + 244 + testWidgets('kebab menu refresh action triggers loadCount', (tester) async { 245 + await tester.pumpWidget(buildSubject()); 246 + await tester.tap(find.byTooltip('Search index actions')); 247 + await tester.pumpAndSettle(); 248 + await tester.tap(find.text('Refresh indexed count')); 249 + await tester.pumpAndSettle(); 250 + verify(() => indexCubit.loadCount()).called(2); 251 + }); 252 + 253 + testWidgets('kebab menu reindex action triggers reindex', (tester) async { 206 254 await tester.pumpWidget(buildSubject()); 207 - expect(find.textContaining('Indexing:'), findsNothing); 255 + await tester.tap(find.byTooltip('Search index actions')); 256 + await tester.pumpAndSettle(); 257 + await tester.tap(find.text('Re-index posts')); 258 + await tester.pump(); 259 + verify(() => indexCubit.reindex()).called(1); 260 + }); 261 + 262 + testWidgets('semantic settings sheet opens from kebab menu', (tester) async { 263 + await tester.pumpWidget(buildSubject()); 264 + await tester.tap(find.byTooltip('Search index actions')); 265 + await tester.pumpAndSettle(); 266 + await tester.tap(find.text('Semantic settings')); 267 + await tester.pumpAndSettle(); 268 + expect(find.text('Semantic settings'), findsOneWidget); 269 + expect(find.text('Enable semantic search'), findsOneWidget); 270 + }); 271 + 272 + testWidgets('sheet toggle updates semantic search enabled setting', (tester) async { 273 + await tester.pumpWidget(buildSubject()); 274 + await tester.tap(find.byTooltip('Search index actions')); 275 + await tester.pumpAndSettle(); 276 + await tester.tap(find.text('Semantic settings')); 277 + await tester.pumpAndSettle(); 278 + await tester.tap(find.byType(Switch)); 279 + await tester.pumpAndSettle(); 280 + verify(() => settingsCubit.setSemanticSearchEnabled(false)).called(1); 208 281 }); 209 282 }); 210 283 }
+5 -146
test/features/settings/presentation/search_settings_test.dart
··· 6 6 import 'package:lazurite/core/theme/feed_layout.dart'; 7 7 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 8 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 9 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 12 10 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 13 11 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; ··· 19 17 20 18 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 21 19 22 - class MockSemanticIndexCubit extends MockCubit<SemanticIndexState> implements SemanticIndexCubit {} 23 - 24 - class MockSemanticSearchCubit extends MockCubit<SemanticSearchState> implements SemanticSearchCubit {} 25 - 26 20 SettingsState _baseSettings({bool semanticSearchEnabled = false, int maxResults = 20}) => SettingsState( 27 21 themePalette: AppThemePalette.oxocarbon, 28 22 themeVariant: AppThemeVariant.dark, ··· 36 30 late MockAuthBloc authBloc; 37 31 late MockAccountSwitcherCubit accountSwitcherCubit; 38 32 late MockSettingsCubit settingsCubit; 39 - late MockSemanticIndexCubit indexCubit; 40 - late MockSemanticSearchCubit searchCubit; 41 33 42 34 setUp(() { 43 35 authBloc = MockAuthBloc(); 44 36 accountSwitcherCubit = MockAccountSwitcherCubit(); 45 37 settingsCubit = MockSettingsCubit(); 46 - indexCubit = MockSemanticIndexCubit(); 47 - searchCubit = MockSemanticSearchCubit(); 48 38 49 39 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 50 40 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); ··· 59 49 when(() => settingsCubit.state).thenReturn(initialSettings); 60 50 whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: initialSettings); 61 51 when(() => settingsCubit.setTypeaheadProvider(any())).thenAnswer((_) async {}); 62 - 63 - when(() => indexCubit.state).thenReturn(const SemanticIndexState()); 64 - whenListen(indexCubit, const Stream<SemanticIndexState>.empty(), initialState: const SemanticIndexState()); 65 - 66 - when(() => searchCubit.state).thenReturn(const SemanticSearchState()); 67 - whenListen(searchCubit, const Stream<SemanticSearchState>.empty(), initialState: const SemanticSearchState()); 68 52 }); 69 53 70 54 Widget buildSubject() { ··· 73 57 BlocProvider<AuthBloc>.value(value: authBloc), 74 58 BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 75 59 BlocProvider<SettingsCubit>.value(value: settingsCubit), 76 - BlocProvider<SemanticIndexCubit>.value(value: indexCubit), 77 - BlocProvider<SemanticSearchCubit>.value(value: searchCubit), 78 60 ], 79 61 child: const MaterialApp(home: SettingsScreen()), 80 62 ); ··· 113 95 verify(() => settingsCubit.setTypeaheadProvider('community')).called(1); 114 96 }); 115 97 116 - testWidgets('shows Semantic Search toggle set to off by default', (tester) async { 98 + testWidgets('shows semantic search management hint', (tester) async { 117 99 await tester.pumpWidget(buildSubject()); 118 100 await tester.pumpAndSettle(); 119 101 await tester.scrollUntilVisible(find.text('Semantic Search'), 300); 120 - final semanticTile = find.ancestor(of: find.text('Semantic Search'), matching: find.byType(ListTile)); 121 - final switchWidget = tester.widget<Switch>(find.descendant(of: semanticTile, matching: find.byType(Switch))); 122 - expect(switchWidget.value, isFalse); 123 - }); 124 - 125 - testWidgets('toggling Semantic Search on calls setSemanticSearchEnabled and reindex', (tester) async { 126 - await tester.binding.setSurfaceSize(const Size(800, 2400)); 127 - addTearDown(() => tester.binding.setSurfaceSize(null)); 128 - 129 - when(() => settingsCubit.setSemanticSearchEnabled(any())).thenAnswer((_) async {}); 130 - when(() => indexCubit.reindex()).thenAnswer((_) async {}); 131 - 132 - await tester.pumpWidget(buildSubject()); 133 - await tester.pumpAndSettle(); 134 - 135 - final semanticTile = find.ancestor(of: find.text('Semantic Search'), matching: find.byType(ListTile)); 136 - await tester.tap(find.descendant(of: semanticTile, matching: find.byType(Switch))); 137 - await tester.pumpAndSettle(); 138 - 139 - verify(() => settingsCubit.setSemanticSearchEnabled(true)).called(1); 140 - verify(() => indexCubit.reindex()).called(1); 102 + expect(find.text('Manage semantic search from Bookmarks & Likes -> Search'), findsOneWidget); 141 103 }); 142 104 143 - testWidgets('shows additional settings when semantic search is enabled', (tester) async { 144 - final enabledSettings = _baseSettings(semanticSearchEnabled: true); 145 - when(() => settingsCubit.state).thenReturn(enabledSettings); 146 - whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 147 - 148 - await tester.pumpWidget(buildSubject()); 149 - await tester.pumpAndSettle(); 150 - await tester.scrollUntilVisible(find.text('Default Scope'), 300); 151 - 152 - expect(find.text('Default Scope'), findsOneWidget); 153 - expect(find.text('Index Status'), findsOneWidget); 154 - expect(find.text('Max Results'), findsOneWidget); 155 - }); 156 - 157 - testWidgets('hides advanced settings when semantic search is disabled', (tester) async { 105 + testWidgets('does not show semantic search controls in settings', (tester) async { 158 106 await tester.pumpWidget(buildSubject()); 159 107 await tester.pumpAndSettle(); 160 - 161 108 expect(find.text('Default Scope'), findsNothing); 162 - expect(find.text('Index Status'), findsNothing); 163 109 expect(find.text('Max Results'), findsNothing); 164 - }); 165 - 166 - testWidgets('shows indexed post count in Index Status tile', (tester) async { 167 - when(() => indexCubit.state).thenReturn(const SemanticIndexState(indexedCount: 73)); 168 - whenListen( 169 - indexCubit, 170 - const Stream<SemanticIndexState>.empty(), 171 - initialState: const SemanticIndexState(indexedCount: 73), 172 - ); 173 - 174 - final enabledSettings = _baseSettings(semanticSearchEnabled: true); 175 - when(() => settingsCubit.state).thenReturn(enabledSettings); 176 - whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 177 - 178 - await tester.pumpWidget(buildSubject()); 179 - await tester.pumpAndSettle(); 180 - await tester.scrollUntilVisible(find.text('73 posts indexed'), 300); 181 - 182 - expect(find.text('73 posts indexed'), findsOneWidget); 183 - }); 184 - 185 - testWidgets('shows backfill progress in Index Status tile while indexing', (tester) async { 186 - when(() => indexCubit.state).thenReturn( 187 - const SemanticIndexState(status: SemanticIndexStatus.backfilling, backfillCompleted: 30, backfillTotal: 100), 188 - ); 189 - whenListen( 190 - indexCubit, 191 - const Stream<SemanticIndexState>.empty(), 192 - initialState: const SemanticIndexState( 193 - status: SemanticIndexStatus.backfilling, 194 - backfillCompleted: 30, 195 - backfillTotal: 100, 196 - ), 197 - ); 198 - 199 - final enabledSettings = _baseSettings(semanticSearchEnabled: true); 200 - when(() => settingsCubit.state).thenReturn(enabledSettings); 201 - whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 202 - 203 - await tester.pumpWidget(buildSubject()); 204 - await tester.pumpAndSettle(); 205 - await tester.scrollUntilVisible(find.text('Indexing: 30/100 posts...'), 300); 206 - 207 - expect(find.text('Indexing: 30/100 posts...'), findsOneWidget); 208 - }); 209 - 210 - testWidgets('Re-index button triggers reindex', (tester) async { 211 - await tester.binding.setSurfaceSize(const Size(800, 2400)); 212 - addTearDown(() => tester.binding.setSurfaceSize(null)); 213 - 214 - when(() => indexCubit.reindex()).thenAnswer((_) async {}); 215 - 216 - final enabledSettings = _baseSettings(semanticSearchEnabled: true); 217 - when(() => settingsCubit.state).thenReturn(enabledSettings); 218 - whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 219 - 220 - await tester.pumpWidget(buildSubject()); 221 - await tester.pumpAndSettle(); 222 - 223 - await tester.tap(find.text('Re-index')); 224 - await tester.pump(); 225 - 226 - verify(() => indexCubit.reindex()).called(1); 227 - }); 228 - 229 - testWidgets('max results slider reflects current settings value', (tester) async { 230 - final enabledSettings = _baseSettings(semanticSearchEnabled: true, maxResults: 30); 231 - when(() => settingsCubit.state).thenReturn(enabledSettings); 232 - whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 233 - 234 - await tester.pumpWidget(buildSubject()); 235 - await tester.pumpAndSettle(); 236 - await tester.scrollUntilVisible(find.text('30'), 300); 237 - 238 - expect(find.text('30'), findsOneWidget); 239 - final slider = tester.widget<Slider>(find.byType(Slider)); 240 - expect(slider.value, 30.0); 241 - }); 242 - 243 - testWidgets('default scope dropdown shows current scope', (tester) async { 244 - final enabledSettings = _baseSettings(semanticSearchEnabled: true).copyWith(searchScope: SearchScope.saved); 245 - when(() => settingsCubit.state).thenReturn(enabledSettings); 246 - whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: enabledSettings); 247 - 248 - await tester.pumpWidget(buildSubject()); 249 - await tester.pumpAndSettle(); 250 - await tester.scrollUntilVisible(find.text('Saved only'), 300); 251 - 252 - expect(find.text('Saved only'), findsOneWidget); 110 + final semanticTile = find.ancestor(of: find.text('Semantic Search'), matching: find.byType(ListTile)); 111 + expect(find.descendant(of: semanticTile, matching: find.byType(Switch)), findsNothing); 253 112 }); 254 113 }); 255 114 }