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: configurable typeahead provider selection (data layer)

+814 -40
+12 -38
docs/tasks/typeahead.md
··· 2 2 3 3 ## M0 - Data Layer 4 4 5 - - [ ] Create `lib/features/typeahead/data/typeahead_result.dart` - `TypeaheadResult` model (`did`, `handle`, `displayName`, `avatarUrl`, `labels`) 6 - - Factory `fromProfileViewBasic` for Bluesky backend 7 - - Factory `fromJson` for community backend raw JSON 8 - - [ ] Create `lib/features/typeahead/data/typeahead_repository.dart` 9 - - Constructor takes optional `Bluesky`, required `provider` string, optional `ModerationService` 10 - - `search(query, limit)` dispatches to Bluesky SDK or HTTP based on provider 11 - - Bluesky path: `bluesky.actor.searchActorsTypeahead(q:, limit:)` + moderation headers + filtering 12 - - Community path: HTTP GET to `https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead?q=&limit=` with `X-Client: lazurite` header 13 - - Fallback: community failure → Bluesky endpoint (when session available), log via `AppLogger` 14 - - [ ] Unit tests for `TypeaheadRepository` 15 - - Bluesky provider delegates to SDK, applies moderation filtering 16 - - Community provider makes HTTP request, parses JSON, applies local moderation 17 - - Fallback triggers on community error when Bluesky available 18 - - Fallback does not trigger when no session (login context) 5 + - [x] Create `lib/features/typeahead/data/typeahead_result.dart` - `TypeaheadResult` model (`did`, `handle`, `displayName`, `avatarUrl`, `labels`) 6 + - [x] Create `lib/features/typeahead/data/typeahead_repository.dart` 7 + - [x] Unit tests for `TypeaheadRepository` 19 8 20 9 ## M1 - Settings Integration 21 10 22 - - [ ] Add `typeahead_provider` column to settings table (Drift migration) 23 - - Type: text, default: `'bluesky'`, allowed: `'bluesky'` | `'community'` 24 - - [ ] Add `typeaheadProvider` to `SettingsState` 25 - - [ ] Add `setTypeaheadProvider(String)` to `SettingsCubit` 26 - - [ ] Settings UI: add "Typeahead Provider" option under a "Search" section 27 - - Radio/segmented control: Bluesky / Community (waow.tech) 28 - - Brief description for each, note community is third-party 29 - - [ ] Unit test: `SettingsCubit` persists and restores typeahead provider 30 - - [ ] Widget test: settings screen shows typeahead provider selector 11 + - [x] Add `typeahead_provider` column to settings table (Drift migration) 12 + - [x] Add `typeaheadProvider` to `SettingsState` 13 + - [x] Add `setTypeaheadProvider(String)` to `SettingsCubit` 14 + - [x] Settings UI: add "Typeahead Provider" option under a "Search" section 15 + - [x] Unit test: `SettingsCubit` persists and restores typeahead provider 16 + - [x] Widget test: settings screen shows typeahead provider selector 31 17 32 18 ## M2 - TypeaheadCubit 33 19 34 - - [ ] Create `lib/features/typeahead/cubit/typeahead_cubit.dart` 35 - - [ ] Create `lib/features/typeahead/cubit/typeahead_state.dart` 36 - - State: `results`, `isLoading`, `error` 37 - - Methods: `onQueryChanged(String)` (300ms debounce), `clear()` 38 - - Cancel in-flight on new query 39 - - Empty/whitespace → emit empty results immediately 40 - - [ ] Unit tests: debounce fires, cancel-on-new-query, empty input handling 20 + - [x] Create `lib/features/typeahead/cubit/typeahead_cubit.dart` 21 + - [x] Create `lib/features/typeahead/cubit/typeahead_state.dart` 22 + - [x] Unit tests: debounce fires, cancel-on-new-query, empty input handling 41 23 42 24 ## M3 - TypeaheadTextField Widget 43 25 ··· 72 54 - [ ] Update starter pack member search to use `TypeaheadRepository` 73 55 - [ ] Unit tests: `SearchBloc` typeahead delegates to `TypeaheadRepository` 74 56 - [ ] Widget tests: search typeahead renders results from configured provider 75 - 76 - ## M6 - Polish & Validation 77 - 78 - - [ ] Verify rate limiting: 300ms debounce keeps community usage well under 60 req/min 79 - - [ ] Graceful degradation: community results without `viewer` → hide follow badge 80 - - [ ] Error handling: network timeout → show inline error, not crash 81 - - [ ] `flutter analyze` clean 82 - - [ ] Full test suite passes
+5 -1
lib/core/database/app_database.dart
··· 26 26 static const activeAccountDidSettingKey = 'active_account_did'; 27 27 28 28 @override 29 - int get schemaVersion => 16; 29 + int get schemaVersion => 17; 30 30 31 31 @override 32 32 MigrationStrategy get migration => MigrationStrategy( 33 33 onCreate: (migrator) async { 34 34 await migrator.createAll(); 35 + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); 35 36 }, 36 37 onUpgrade: (migrator, from, to) async { 37 38 if (from < 2) { ··· 114 115 AND dpop_public_key IS NOT NULL 115 116 AND dpop_private_key IS NOT NULL 116 117 '''); 118 + } 119 + if (from < 17) { 120 + await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); 117 121 } 118 122 }, 119 123 );
+22
lib/features/settings/bloc/settings_cubit.dart
··· 44 44 static const String _keySemanticSearchEnabled = 'semantic_search_enabled'; 45 45 static const String _keySearchScope = 'search_scope'; 46 46 static const String _keySemanticSearchMaxResults = 'semantic_search_max_results'; 47 + static const String _keyTypeaheadProvider = 'typeahead_provider'; 48 + static const String _defaultTypeaheadProvider = 'bluesky'; 49 + static const Set<String> _supportedTypeaheadProviders = {'bluesky', 'community'}; 47 50 48 51 Future<void> loadSettings() async { 49 52 final paletteStr = await database.getSetting(_keyThemePalette); ··· 58 61 final semanticSearchEnabledStr = await database.getSetting(_keySemanticSearchEnabled); 59 62 final searchScopeStr = await database.getSetting(_keySearchScope); 60 63 final semanticSearchMaxResultsStr = await database.getSetting(_keySemanticSearchMaxResults); 64 + final typeaheadProviderStr = await database.getSetting(_keyTypeaheadProvider); 65 + final resolvedTypeaheadProvider = _supportedTypeaheadProviders.contains(typeaheadProviderStr) 66 + ? typeaheadProviderStr! 67 + : _defaultTypeaheadProvider; 61 68 62 69 emit( 63 70 state.copyWith( ··· 72 79 semanticSearchEnabled: semanticSearchEnabledStr == 'true', 73 80 searchScope: SearchScope.values.firstWhere((s) => s.name == searchScopeStr, orElse: () => SearchScope.both), 74 81 semanticSearchMaxResults: int.tryParse(semanticSearchMaxResultsStr ?? '') ?? 20, 82 + typeaheadProvider: resolvedTypeaheadProvider, 75 83 ), 76 84 ); 77 85 } ··· 140 148 Future<void> setSemanticSearchMaxResults(int value) async { 141 149 await database.setSetting(_keySemanticSearchMaxResults, value.toString()); 142 150 emit(state.copyWith(semanticSearchMaxResults: value)); 151 + } 152 + 153 + Future<void> setTypeaheadProvider(String provider) async { 154 + final normalizedProvider = provider.trim().toLowerCase(); 155 + if (!_supportedTypeaheadProviders.contains(normalizedProvider)) { 156 + throw ArgumentError.value( 157 + provider, 158 + 'provider', 159 + 'Supported typeahead providers are: ${_supportedTypeaheadProviders.join(', ')}.', 160 + ); 161 + } 162 + 163 + await database.setSetting(_keyTypeaheadProvider, normalizedProvider); 164 + emit(state.copyWith(typeaheadProvider: normalizedProvider)); 143 165 } 144 166 }
+7
lib/features/settings/bloc/settings_state.dart
··· 18 18 this.semanticSearchEnabled = false, 19 19 this.searchScope = SearchScope.both, 20 20 this.semanticSearchMaxResults = 20, 21 + this.typeaheadProvider = 'bluesky', 21 22 }); 22 23 23 24 final AppThemePalette themePalette; ··· 37 38 38 39 /// Maximum number of results returned per search query (10–50). 39 40 final int semanticSearchMaxResults; 41 + 42 + /// Configured typeahead backend provider (`bluesky` or `community`). 43 + final String typeaheadProvider; 40 44 41 45 SettingsState copyWith({ 42 46 AppThemePalette? themePalette, ··· 50 54 bool? semanticSearchEnabled, 51 55 SearchScope? searchScope, 52 56 int? semanticSearchMaxResults, 57 + String? typeaheadProvider, 53 58 }) { 54 59 return SettingsState( 55 60 themePalette: themePalette ?? this.themePalette, ··· 65 70 semanticSearchEnabled: semanticSearchEnabled ?? this.semanticSearchEnabled, 66 71 searchScope: searchScope ?? this.searchScope, 67 72 semanticSearchMaxResults: semanticSearchMaxResults ?? this.semanticSearchMaxResults, 73 + typeaheadProvider: typeaheadProvider ?? this.typeaheadProvider, 68 74 ); 69 75 } 70 76 ··· 81 87 semanticSearchEnabled, 82 88 searchScope, 83 89 semanticSearchMaxResults, 90 + typeaheadProvider, 84 91 ]; 85 92 }
+26
lib/features/settings/presentation/settings_screen.dart
··· 316 316 ), 317 317 child: Column( 318 318 children: [ 319 + ListTile( 320 + leading: const Icon(Icons.tune_outlined), 321 + title: const Text('Typeahead Provider'), 322 + subtitle: Text( 323 + settingsState.typeaheadProvider == 'community' 324 + ? 'Community (waow.tech) selected. Third-party service, works before login.' 325 + : 'Bluesky official endpoint selected. Requires login.', 326 + ), 327 + ), 328 + Padding( 329 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 330 + child: Align( 331 + alignment: Alignment.centerLeft, 332 + child: SegmentedButton<String>( 333 + segments: const [ 334 + ButtonSegment<String>(value: 'bluesky', label: Text('Bluesky')), 335 + ButtonSegment<String>(value: 'community', label: Text('Community')), 336 + ], 337 + selected: {settingsState.typeaheadProvider}, 338 + onSelectionChanged: (selection) { 339 + context.read<SettingsCubit>().setTypeaheadProvider(selection.first); 340 + }, 341 + ), 342 + ), 343 + ), 344 + const Divider(height: 1), 319 345 _SettingsTile( 320 346 icon: Icons.manage_search_outlined, 321 347 title: 'Semantic Search',
+75
lib/features/typeahead/cubit/typeahead_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/typeahead/cubit/typeahead_state.dart'; 5 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 6 + 7 + class TypeaheadCubit extends Cubit<TypeaheadState> { 8 + TypeaheadCubit({ 9 + required TypeaheadRepository repository, 10 + Duration debounceDuration = const Duration(milliseconds: 300), 11 + }) : _repository = repository, 12 + _debounceDuration = debounceDuration, 13 + super(const TypeaheadState()); 14 + 15 + final TypeaheadRepository _repository; 16 + final Duration _debounceDuration; 17 + 18 + Timer? _debounceTimer; 19 + int _requestId = 0; 20 + 21 + void onQueryChanged(String query) { 22 + final normalizedQuery = query.trim(); 23 + 24 + _debounceTimer?.cancel(); 25 + _requestId++; 26 + 27 + if (normalizedQuery.isEmpty) { 28 + emit(const TypeaheadState()); 29 + return; 30 + } 31 + 32 + final requestId = _requestId; 33 + _debounceTimer = Timer(_debounceDuration, () { 34 + unawaited(_runSearch(requestId: requestId, query: normalizedQuery)); 35 + }); 36 + } 37 + 38 + void clear() { 39 + _debounceTimer?.cancel(); 40 + _requestId++; 41 + 42 + if (state == const TypeaheadState()) { 43 + return; 44 + } 45 + 46 + emit(const TypeaheadState()); 47 + } 48 + 49 + Future<void> _runSearch({required int requestId, required String query}) async { 50 + emit(state.copyWith(isLoading: true, error: null)); 51 + 52 + try { 53 + final results = await _repository.search(query: query); 54 + if (!_isActiveRequest(requestId)) { 55 + return; 56 + } 57 + 58 + emit(TypeaheadState(results: results)); 59 + } catch (_) { 60 + if (!_isActiveRequest(requestId)) { 61 + return; 62 + } 63 + 64 + emit(const TypeaheadState(error: 'Failed to load suggestions')); 65 + } 66 + } 67 + 68 + bool _isActiveRequest(int requestId) => requestId == _requestId; 69 + 70 + @override 71 + Future<void> close() { 72 + _debounceTimer?.cancel(); 73 + return super.close(); 74 + } 75 + }
+23
lib/features/typeahead/cubit/typeahead_state.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 3 + 4 + const _errorUnset = Object(); 5 + 6 + class TypeaheadState extends Equatable { 7 + const TypeaheadState({this.results = const [], this.isLoading = false, this.error}); 8 + 9 + final List<TypeaheadResult> results; 10 + final bool isLoading; 11 + final String? error; 12 + 13 + TypeaheadState copyWith({List<TypeaheadResult>? results, bool? isLoading, Object? error = _errorUnset}) { 14 + return TypeaheadState( 15 + results: results ?? this.results, 16 + isLoading: isLoading ?? this.isLoading, 17 + error: identical(error, _errorUnset) ? this.error : error as String?, 18 + ); 19 + } 20 + 21 + @override 22 + List<Object?> get props => [results, isLoading, error]; 23 + }
+130
lib/features/typeahead/data/typeahead_repository.dart
··· 1 + import 'dart:convert'; 2 + import 'dart:io'; 3 + 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:http/http.dart' as http; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 8 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 9 + 10 + class TypeaheadRepository { 11 + TypeaheadRepository({ 12 + dynamic bluesky, 13 + required String provider, 14 + ModerationService? moderationService, 15 + http.Client? httpClient, 16 + }) : _bluesky = bluesky, 17 + _provider = provider.trim().toLowerCase(), 18 + _moderationService = moderationService, 19 + _httpClient = httpClient ?? http.Client() { 20 + if (!_isSupportedProvider(_provider)) { 21 + throw ArgumentError.value(provider, 'provider', 'Supported providers are "bluesky" and "community".'); 22 + } 23 + } 24 + 25 + static const String blueskyProvider = 'bluesky'; 26 + static const String communityProvider = 'community'; 27 + 28 + static const String _communityHost = 'typeahead.waow.tech'; 29 + static const String _communityPath = '/xrpc/app.bsky.actor.searchActorsTypeahead'; 30 + 31 + final dynamic _bluesky; 32 + final String _provider; 33 + final ModerationService? _moderationService; 34 + final http.Client _httpClient; 35 + 36 + Future<List<TypeaheadResult>> search({required String query, int limit = 10}) async { 37 + final normalizedQuery = query.trim(); 38 + if (normalizedQuery.isEmpty) { 39 + return const []; 40 + } 41 + 42 + final normalizedLimit = limit.clamp(1, 100); 43 + 44 + if (_provider == blueskyProvider) { 45 + return _searchBluesky(query: normalizedQuery, limit: normalizedLimit); 46 + } 47 + 48 + try { 49 + return await _searchCommunity(query: normalizedQuery, limit: normalizedLimit); 50 + } catch (error, stackTrace) { 51 + if (_bluesky == null) { 52 + rethrow; 53 + } 54 + 55 + log.w( 56 + 'TypeaheadRepository: community provider failed; falling back to Bluesky provider.', 57 + error: error, 58 + stackTrace: stackTrace, 59 + ); 60 + 61 + return _searchBluesky(query: normalizedQuery, limit: normalizedLimit); 62 + } 63 + } 64 + 65 + Future<List<TypeaheadResult>> _searchBluesky({required String query, required int limit}) async { 66 + final bluesky = _bluesky; 67 + if (bluesky == null) { 68 + throw StateError('Bluesky provider requires an authenticated Bluesky client.'); 69 + } 70 + 71 + final response = await bluesky.actor.searchActorsTypeahead( 72 + q: query, 73 + limit: limit, 74 + $headers: await _moderationService?.headersForRequest(), 75 + ); 76 + 77 + final results = (response.data.actors as List) 78 + .whereType<ProfileViewBasic>() 79 + .map(TypeaheadResult.fromProfileViewBasic) 80 + .toList(growable: false); 81 + return _applyModeration(results); 82 + } 83 + 84 + Future<List<TypeaheadResult>> _searchCommunity({required String query, required int limit}) async { 85 + final uri = Uri.https(_communityHost, _communityPath, {'q': query, 'limit': limit.toString()}); 86 + final response = await _httpClient.get(uri, headers: const {'X-Client': 'lazurite'}); 87 + 88 + if (response.statusCode < 200 || response.statusCode >= 300) { 89 + throw HttpException('Community typeahead request failed: HTTP ${response.statusCode}', uri: uri); 90 + } 91 + 92 + final decoded = jsonDecode(response.body); 93 + if (decoded is! Map<String, dynamic>) { 94 + throw const FormatException('Community typeahead response was not a JSON object.'); 95 + } 96 + 97 + final actors = decoded['actors']; 98 + if (actors is! List) { 99 + return const []; 100 + } 101 + 102 + final results = <TypeaheadResult>[]; 103 + for (final actor in actors) { 104 + if (actor is! Map<String, dynamic>) { 105 + continue; 106 + } 107 + 108 + try { 109 + results.add(TypeaheadResult.fromJson(actor)); 110 + } catch (error, stackTrace) { 111 + log.w('TypeaheadRepository: skipped invalid community actor payload.', error: error, stackTrace: stackTrace); 112 + } 113 + } 114 + 115 + return _applyModeration(results); 116 + } 117 + 118 + List<TypeaheadResult> _applyModeration(List<TypeaheadResult> results) { 119 + final moderationService = _moderationService; 120 + if (moderationService == null) { 121 + return results; 122 + } 123 + 124 + return results 125 + .where((result) => !moderationService.shouldFilterProfileBasicInList(result.toProfileViewBasic())) 126 + .toList(growable: false); 127 + } 128 + 129 + static bool _isSupportedProvider(String provider) => provider == blueskyProvider || provider == communityProvider; 130 + }
+71
lib/features/typeahead/data/typeahead_result.dart
··· 1 + import 'package:atproto/com_atproto_label_defs.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:equatable/equatable.dart'; 4 + 5 + class TypeaheadResult extends Equatable { 6 + const TypeaheadResult({ 7 + required this.did, 8 + required this.handle, 9 + this.displayName, 10 + this.avatarUrl, 11 + this.labels = const [], 12 + }); 13 + 14 + factory TypeaheadResult.fromProfileViewBasic(ProfileViewBasic profile) { 15 + return TypeaheadResult( 16 + did: profile.did, 17 + handle: profile.handle, 18 + displayName: profile.displayName, 19 + avatarUrl: profile.avatar, 20 + labels: List<Label>.unmodifiable(profile.labels ?? const []), 21 + ); 22 + } 23 + 24 + factory TypeaheadResult.fromJson(Map<String, dynamic> json) { 25 + final rawLabels = json['labels']; 26 + final labels = <Label>[]; 27 + 28 + if (rawLabels is List) { 29 + for (final rawLabel in rawLabels) { 30 + if (rawLabel is Map<String, dynamic>) { 31 + labels.add(Label.fromJson(rawLabel)); 32 + } 33 + } 34 + } 35 + 36 + return TypeaheadResult( 37 + did: _requiredString(json, 'did'), 38 + handle: _requiredString(json, 'handle'), 39 + displayName: _optionalString(json, 'displayName'), 40 + avatarUrl: _optionalString(json, 'avatar'), 41 + labels: List<Label>.unmodifiable(labels), 42 + ); 43 + } 44 + 45 + final String did; 46 + final String handle; 47 + final String? displayName; 48 + final String? avatarUrl; 49 + final List<Label> labels; 50 + 51 + ProfileViewBasic toProfileViewBasic() { 52 + return ProfileViewBasic(did: did, handle: handle, displayName: displayName, avatar: avatarUrl, labels: labels); 53 + } 54 + 55 + static String _requiredString(Map<String, dynamic> json, String key) { 56 + final value = json[key]; 57 + if (value is String && value.isNotEmpty) { 58 + return value; 59 + } 60 + 61 + throw FormatException('Invalid or missing "$key" field in typeahead result payload.'); 62 + } 63 + 64 + static String? _optionalString(Map<String, dynamic> json, String key) { 65 + final value = json[key]; 66 + return value is String && value.isNotEmpty ? value : null; 67 + } 68 + 69 + @override 70 + List<Object?> get props => [did, handle, displayName, avatarUrl, labels]; 71 + }
+5
test/core/database/app_database_test.dart
··· 223 223 }); 224 224 225 225 group('Settings operations', () { 226 + test('should seed default typeahead provider on database creation', () async { 227 + final value = await database.getSetting('typeahead_provider'); 228 + expect(value, equals('bluesky')); 229 + }); 230 + 226 231 test('should set and get setting', () async { 227 232 await database.setSetting('theme', 'dark'); 228 233 final value = await database.getSetting('theme');
+22 -1
test/features/settings/bloc/settings_cubit_test.dart
··· 87 87 .having((s) => s.feedLayout, 'feedLayout', FeedLayout.card) 88 88 .having((s) => s.animationsEnabled, 'animationsEnabled', true) 89 89 .having((s) => s.simulateOffline, 'simulateOffline', false) 90 - .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull), 90 + .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull) 91 + .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky'), 91 92 ], 92 93 ); 93 94 ··· 283 284 'https://constellation.microcosm.blue', 284 285 ), 285 286 ], 287 + ); 288 + 289 + blocTest<SettingsCubit, SettingsState>( 290 + 'setTypeaheadProvider updates state and persists to database', 291 + build: () => SettingsCubit(database: database), 292 + act: (cubit) => cubit.setTypeaheadProvider('community'), 293 + expect: () => [isA<SettingsState>().having((s) => s.typeaheadProvider, 'typeaheadProvider', 'community')], 294 + verify: (_) async { 295 + expect(await database.getSetting('typeahead_provider'), 'community'); 296 + }, 297 + ); 298 + 299 + blocTest<SettingsCubit, SettingsState>( 300 + 'loadSettings restores persisted typeahead provider', 301 + build: () => SettingsCubit(database: database), 302 + setUp: () async { 303 + await database.setSetting('typeahead_provider', 'community'); 304 + }, 305 + act: (cubit) => cubit.loadSettings(), 306 + expect: () => [isA<SettingsState>().having((s) => s.typeaheadProvider, 'typeaheadProvider', 'community')], 286 307 ); 287 308 }); 288 309 }
+26
test/features/settings/presentation/search_settings_test.dart
··· 58 58 final initialSettings = _baseSettings(); 59 59 when(() => settingsCubit.state).thenReturn(initialSettings); 60 60 whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: initialSettings); 61 + when(() => settingsCubit.setTypeaheadProvider(any())).thenAnswer((_) async {}); 61 62 62 63 when(() => indexCubit.state).thenReturn(const SemanticIndexState()); 63 64 whenListen(indexCubit, const Stream<SemanticIndexState>.empty(), initialState: const SemanticIndexState()); ··· 85 86 await tester.pumpAndSettle(); 86 87 await tester.scrollUntilVisible(find.text('SEARCH'), 300); 87 88 expect(find.text('SEARCH'), findsOneWidget); 89 + }); 90 + 91 + testWidgets('shows typeahead provider selector', (tester) async { 92 + await tester.pumpWidget(buildSubject()); 93 + await tester.pumpAndSettle(); 94 + await tester.scrollUntilVisible(find.text('Typeahead Provider'), 300); 95 + 96 + expect(find.text('Typeahead Provider'), findsOneWidget); 97 + expect(find.text('Bluesky official endpoint selected. Requires login.'), findsOneWidget); 98 + expect(find.text('Bluesky'), findsOneWidget); 99 + expect(find.text('Community'), findsOneWidget); 100 + }); 101 + 102 + testWidgets('selecting community provider calls setTypeaheadProvider', (tester) async { 103 + await tester.binding.setSurfaceSize(const Size(800, 2400)); 104 + addTearDown(() => tester.binding.setSurfaceSize(null)); 105 + 106 + await tester.pumpWidget(buildSubject()); 107 + await tester.pumpAndSettle(); 108 + await tester.scrollUntilVisible(find.text('Typeahead Provider'), 300); 109 + 110 + await tester.tap(find.text('Community')); 111 + await tester.pumpAndSettle(); 112 + 113 + verify(() => settingsCubit.setTypeaheadProvider('community')).called(1); 88 114 }); 89 115 90 116 testWidgets('shows Semantic Search toggle set to off by default', (tester) async {
+123
test/features/typeahead/cubit/typeahead_cubit_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/typeahead/cubit/typeahead_cubit.dart'; 6 + import 'package:lazurite/features/typeahead/cubit/typeahead_state.dart'; 7 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 8 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 9 + 10 + void main() { 11 + group('TypeaheadCubit', () { 12 + blocTest<TypeaheadCubit, TypeaheadState>( 13 + 'debounces query changes and emits loading then results', 14 + build: () { 15 + final repository = _FakeTypeaheadRepository( 16 + searchHandler: ({required String query, int limit = 10}) async { 17 + expect(query, 'alice'); 18 + return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social')]; 19 + }, 20 + ); 21 + 22 + return TypeaheadCubit(repository: repository, debounceDuration: const Duration(milliseconds: 300)); 23 + }, 24 + act: (cubit) => cubit.onQueryChanged('alice'), 25 + wait: const Duration(milliseconds: 350), 26 + expect: () => [ 27 + const TypeaheadState(isLoading: true), 28 + const TypeaheadState( 29 + results: [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social')], 30 + ), 31 + ], 32 + ); 33 + 34 + test('cancels stale in-flight requests when a new query arrives', () async { 35 + final firstSearchCompleter = Completer<List<TypeaheadResult>>(); 36 + final repository = _FakeTypeaheadRepository( 37 + searchHandler: ({required String query, int limit = 10}) { 38 + if (query == 'alice') { 39 + return firstSearchCompleter.future; 40 + } 41 + 42 + return Future.value(const [TypeaheadResult(did: 'did:plc:bob', handle: 'bob.bsky.social')]); 43 + }, 44 + ); 45 + 46 + final cubit = TypeaheadCubit(repository: repository, debounceDuration: const Duration(milliseconds: 10)); 47 + addTearDown(cubit.close); 48 + 49 + final emittedStates = <TypeaheadState>[]; 50 + final subscription = cubit.stream.listen(emittedStates.add); 51 + addTearDown(subscription.cancel); 52 + 53 + cubit.onQueryChanged('alice'); 54 + await Future<void>.delayed(const Duration(milliseconds: 30)); 55 + cubit.onQueryChanged('bob'); 56 + 57 + await Future<void>.delayed(const Duration(milliseconds: 30)); 58 + firstSearchCompleter.complete(const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social')]); 59 + await Future<void>.delayed(const Duration(milliseconds: 30)); 60 + 61 + expect(cubit.state.results, const [TypeaheadResult(did: 'did:plc:bob', handle: 'bob.bsky.social')]); 62 + expect(cubit.state.isLoading, isFalse); 63 + 64 + final staleResultSeen = emittedStates.any( 65 + (state) => state.results.any((result) => result.handle == 'alice.bsky.social'), 66 + ); 67 + expect(staleResultSeen, isFalse); 68 + }); 69 + 70 + blocTest<TypeaheadCubit, TypeaheadState>( 71 + 'empty or whitespace input clears results immediately', 72 + build: () => TypeaheadCubit( 73 + repository: _FakeTypeaheadRepository( 74 + searchHandler: ({required String query, int limit = 10}) async => const [ 75 + TypeaheadResult(did: 'did:plc:ignored', handle: 'ignored.bsky.social'), 76 + ], 77 + ), 78 + ), 79 + seed: () => const TypeaheadState( 80 + results: [TypeaheadResult(did: 'did:plc:seed', handle: 'seed.bsky.social')], 81 + isLoading: true, 82 + error: 'error', 83 + ), 84 + act: (cubit) { 85 + cubit.onQueryChanged(' '); 86 + }, 87 + expect: () => [const TypeaheadState()], 88 + ); 89 + 90 + blocTest<TypeaheadCubit, TypeaheadState>( 91 + 'clear resets state and cancels pending debounce', 92 + build: () { 93 + final repository = _FakeTypeaheadRepository( 94 + searchHandler: ({required String query, int limit = 10}) async { 95 + throw StateError('search should be cancelled by clear()'); 96 + }, 97 + ); 98 + 99 + return TypeaheadCubit(repository: repository, debounceDuration: const Duration(milliseconds: 100)); 100 + }, 101 + seed: () => const TypeaheadState( 102 + results: [TypeaheadResult(did: 'did:plc:seed', handle: 'seed.bsky.social')], 103 + ), 104 + act: (cubit) async { 105 + cubit.onQueryChanged('alice'); 106 + cubit.clear(); 107 + await Future<void>.delayed(const Duration(milliseconds: 150)); 108 + }, 109 + expect: () => [const TypeaheadState()], 110 + ); 111 + }); 112 + } 113 + 114 + class _FakeTypeaheadRepository extends TypeaheadRepository { 115 + _FakeTypeaheadRepository({required this.searchHandler}) : super(provider: TypeaheadRepository.communityProvider); 116 + 117 + final Future<List<TypeaheadResult>> Function({required String query, int limit}) searchHandler; 118 + 119 + @override 120 + Future<List<TypeaheadResult>> search({required String query, int limit = 10}) { 121 + return searchHandler(query: query, limit: limit); 122 + } 123 + }
+267
test/features/typeahead/data/typeahead_repository_test.dart
··· 1 + import 'dart:convert'; 2 + import 'dart:io'; 3 + 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:http/http.dart' as http; 7 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 8 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 9 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 10 + import 'package:mocktail/mocktail.dart'; 11 + 12 + class MockModerationService extends Mock implements ModerationService {} 13 + 14 + void main() { 15 + late MockModerationService moderationService; 16 + 17 + setUpAll(() { 18 + registerFallbackValue(const ProfileViewBasic(did: 'did:plc:fallback', handle: 'fallback.bsky.social')); 19 + }); 20 + 21 + setUp(() { 22 + moderationService = MockModerationService(); 23 + when(() => moderationService.headersForRequest()).thenAnswer((_) async => const {'x-test': 'moderation'}); 24 + when(() => moderationService.shouldFilterProfileBasicInList(any())).thenReturn(false); 25 + }); 26 + 27 + group('TypeaheadRepository', () { 28 + test('bluesky provider delegates to SDK and applies moderation filtering', () async { 29 + final actorService = _FakeActorService() 30 + ..searchActorsResult = const _FakeActorsData( 31 + actors: [ 32 + ProfileViewBasic(did: 'did:plc:keep', handle: 'keep.bsky.social'), 33 + ProfileViewBasic(did: 'did:plc:hide', handle: 'hide.bsky.social'), 34 + ], 35 + ); 36 + 37 + when( 38 + () => moderationService.shouldFilterProfileBasicInList( 39 + any(that: isA<ProfileViewBasic>().having((p) => p.did, 'did', 'did:plc:hide')), 40 + ), 41 + ).thenReturn(true); 42 + 43 + final repository = TypeaheadRepository( 44 + bluesky: _FakeBlueskyClient(actor: actorService), 45 + provider: TypeaheadRepository.blueskyProvider, 46 + moderationService: moderationService, 47 + ); 48 + 49 + final results = await repository.search(query: 'keep', limit: 5); 50 + 51 + expect(results.map((actor) => actor.did).toList(), ['did:plc:keep']); 52 + expect(actorService.lastQuery, 'keep'); 53 + expect(actorService.lastLimit, 5); 54 + expect(actorService.lastHeaders, const {'x-test': 'moderation'}); 55 + }); 56 + 57 + test('community provider makes HTTP request, parses JSON, and applies local moderation', () async { 58 + Uri? requestedUri; 59 + Map<String, String>? requestHeaders; 60 + 61 + final client = _CallbackClient((request) async { 62 + requestedUri = request.url; 63 + requestHeaders = request.headers; 64 + 65 + return http.Response( 66 + jsonEncode({ 67 + 'actors': [ 68 + { 69 + 'did': 'did:plc:keep', 70 + 'handle': 'keep.bsky.social', 71 + 'displayName': 'Keep', 72 + 'avatar': 'https://cdn.example/keep.png', 73 + 'labels': [ 74 + { 75 + r'$type': 'com.atproto.label.defs#label', 76 + 'src': 'did:plc:labeler', 77 + 'uri': 'at://did:plc:keep/app.bsky.actor.profile/self', 78 + 'val': 'spam', 79 + 'cts': '2026-04-28T00:00:00.000Z', 80 + }, 81 + ], 82 + }, 83 + {'did': 'did:plc:hide', 'handle': 'hide.bsky.social'}, 84 + ], 85 + }), 86 + 200, 87 + ); 88 + }); 89 + 90 + when( 91 + () => moderationService.shouldFilterProfileBasicInList( 92 + any(that: isA<ProfileViewBasic>().having((p) => p.did, 'did', 'did:plc:hide')), 93 + ), 94 + ).thenReturn(true); 95 + 96 + final repository = TypeaheadRepository( 97 + provider: TypeaheadRepository.communityProvider, 98 + moderationService: moderationService, 99 + httpClient: client, 100 + ); 101 + 102 + final results = await repository.search(query: 'keep', limit: 12); 103 + 104 + expect(results, hasLength(1)); 105 + expect(results.single.did, 'did:plc:keep'); 106 + expect(results.single.handle, 'keep.bsky.social'); 107 + expect(results.single.displayName, 'Keep'); 108 + expect(results.single.avatarUrl, 'https://cdn.example/keep.png'); 109 + expect(results.single.labels, isNotEmpty); 110 + 111 + expect(requestedUri, isNotNull); 112 + expect(requestedUri!.scheme, 'https'); 113 + expect(requestedUri!.host, 'typeahead.waow.tech'); 114 + expect(requestedUri!.path, '/xrpc/app.bsky.actor.searchActorsTypeahead'); 115 + expect(requestedUri!.queryParameters['q'], 'keep'); 116 + expect(requestedUri!.queryParameters['limit'], '12'); 117 + expect(requestHeaders?['X-Client'], 'lazurite'); 118 + }); 119 + 120 + test('community fallback triggers on error when Bluesky is available', () async { 121 + final actorService = _FakeActorService() 122 + ..searchActorsResult = const _FakeActorsData( 123 + actors: [ProfileViewBasic(did: 'did:plc:fallback', handle: 'fallback.bsky.social')], 124 + ); 125 + 126 + final client = _CallbackClient((_) async => http.Response('upstream unavailable', 503)); 127 + 128 + final repository = TypeaheadRepository( 129 + bluesky: _FakeBlueskyClient(actor: actorService), 130 + provider: TypeaheadRepository.communityProvider, 131 + moderationService: moderationService, 132 + httpClient: client, 133 + ); 134 + 135 + final results = await repository.search(query: 'fallback', limit: 8); 136 + 137 + expect(results.map((actor) => actor.did).toList(), ['did:plc:fallback']); 138 + expect(actorService.lastQuery, 'fallback'); 139 + expect(actorService.lastLimit, 8); 140 + }); 141 + 142 + test('community fallback does not trigger when no Bluesky session/client exists', () async { 143 + final client = _CallbackClient((_) async => throw const SocketException('no route to host')); 144 + 145 + final repository = TypeaheadRepository( 146 + provider: TypeaheadRepository.communityProvider, 147 + moderationService: moderationService, 148 + httpClient: client, 149 + ); 150 + 151 + expect(() => repository.search(query: 'alice', limit: 5), throwsA(isA<SocketException>())); 152 + }); 153 + 154 + test('search returns empty list for empty/whitespace queries', () async { 155 + final client = _CallbackClient((_) async => throw StateError('Should not be called')); 156 + 157 + final repository = TypeaheadRepository( 158 + provider: TypeaheadRepository.communityProvider, 159 + moderationService: moderationService, 160 + httpClient: client, 161 + ); 162 + 163 + expect(await repository.search(query: ''), isEmpty); 164 + expect(await repository.search(query: ' '), isEmpty); 165 + }); 166 + }); 167 + 168 + group('TypeaheadResult', () { 169 + test('fromJson parses community payload', () { 170 + final result = TypeaheadResult.fromJson({ 171 + 'did': 'did:plc:alice', 172 + 'handle': 'alice.bsky.social', 173 + 'displayName': 'Alice', 174 + 'avatar': 'https://cdn.example/avatar.png', 175 + }); 176 + 177 + expect(result.did, 'did:plc:alice'); 178 + expect(result.handle, 'alice.bsky.social'); 179 + expect(result.displayName, 'Alice'); 180 + expect(result.avatarUrl, 'https://cdn.example/avatar.png'); 181 + expect(result.labels, isEmpty); 182 + }); 183 + }); 184 + } 185 + 186 + class _FakeBlueskyClient { 187 + _FakeBlueskyClient({required this.actor}); 188 + 189 + final _FakeActorService actor; 190 + } 191 + 192 + class _FakeActorService { 193 + _FakeActorsData? searchActorsResult; 194 + String? lastQuery; 195 + int? lastLimit; 196 + Map<String, String>? lastHeaders; 197 + 198 + Future<_FakeResponse<_FakeActorsData>> searchActorsTypeahead({ 199 + required String q, 200 + int? limit, 201 + Map<String, String>? $headers, 202 + }) async { 203 + lastQuery = q; 204 + lastLimit = limit; 205 + lastHeaders = $headers; 206 + return _FakeResponse(searchActorsResult!); 207 + } 208 + } 209 + 210 + class _FakeActorsData { 211 + const _FakeActorsData({required this.actors}); 212 + 213 + final List<ProfileViewBasic> actors; 214 + } 215 + 216 + class _FakeResponse<T> { 217 + _FakeResponse(this.data); 218 + 219 + final T data; 220 + } 221 + 222 + class _CallbackClient implements http.Client { 223 + _CallbackClient(this._handler); 224 + 225 + final Future<http.Response> Function(http.Request request) _handler; 226 + 227 + @override 228 + Future<http.StreamedResponse> send(http.BaseRequest request) async { 229 + final httpRequest = http.Request(request.method, request.url) 230 + ..headers.addAll(request.headers) 231 + ..followRedirects = request.followRedirects 232 + ..maxRedirects = request.maxRedirects 233 + ..persistentConnection = request.persistentConnection; 234 + 235 + if (request is http.Request) { 236 + httpRequest.bodyBytes = request.bodyBytes; 237 + } 238 + 239 + final response = await _handler(httpRequest); 240 + return http.StreamedResponse( 241 + Stream<List<int>>.fromIterable([response.bodyBytes]), 242 + response.statusCode, 243 + contentLength: response.contentLength, 244 + request: request, 245 + headers: response.headers, 246 + reasonPhrase: response.reasonPhrase, 247 + isRedirect: response.isRedirect, 248 + persistentConnection: response.persistentConnection, 249 + ); 250 + } 251 + 252 + @override 253 + Future<http.Response> get(Uri url, {Map<String, String>? headers}) async { 254 + final request = http.Request('GET', url); 255 + if (headers != null) { 256 + request.headers.addAll(headers); 257 + } 258 + 259 + return _handler(request); 260 + } 261 + 262 + @override 263 + void close() {} 264 + 265 + @override 266 + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); 267 + }