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: typeahead integration

+458 -240
+19 -16
docs/tasks/typeahead.md
··· 28 28 29 29 ## M4 - Login Integration 30 30 31 - - [ ] Modify `LoginScreen` to use `TypeaheadTextField` for the handle field 32 - - Create `TypeaheadRepository` without `Bluesky`, force `provider: 'community'` 33 - - `onSelected` fills handle controller + triggers `_onOAuthLogin` 34 - - `minChars: 2`, `debounceMs: 300`, `limit: 8` 35 - - [ ] Preserve existing validation (`validator`, `TextInputAction.next`) 36 - - [ ] Preserve debug app-password form (unaffected) 37 - - [ ] Widget test: login typeahead shows community results, selecting triggers login flow 38 - - [ ] Integration test: type handle → see suggestions → tap → OAuth initiates 31 + - [x] Modify `LoginScreen` to use `TypeaheadTextField` for the handle field 32 + - [x] Preserve existing validation (`validator`, `TextInputAction.next`) 33 + - [x] Preserve debug app-password form (unaffected) 34 + - [x] Widget test: login typeahead shows community results, selecting triggers login flow 35 + - [x] Integration test: type handle → see suggestions → tap → OAuth initiates 39 36 40 37 ## M5 - Search Integration 41 38 42 - - [ ] Inject `TypeaheadRepository` into `SearchBloc` (replace direct `SearchRepository.searchActorsTypeahead` usage) 43 - - [ ] Update `SearchBloc._onTypeaheadRequested` to call `TypeaheadRepository.search` 44 - - [ ] Map `TypeaheadResult` back to `ProfileViewBasic` for `state.typeaheadActors` compatibility (or migrate state to `TypeaheadResult`) 45 - - [ ] Update jump-to-profile dialog to use `TypeaheadCubit` + `TypeaheadTextField` 46 - - [ ] Update list member add screen to use `TypeaheadRepository` 47 - - [ ] Update starter pack member search to use `TypeaheadRepository` 48 - - [ ] Unit tests: `SearchBloc` typeahead delegates to `TypeaheadRepository` 49 - - [ ] Widget tests: search typeahead renders results from configured provider 39 + - [x] Inject `TypeaheadRepository` into `SearchBloc` (replace direct `SearchRepository.searchActorsTypeahead` usage) 40 + - [x] Update `SearchBloc._onTypeaheadRequested` to call `TypeaheadRepository.search` 41 + - [x] Map `TypeaheadResult` back to `ProfileViewBasic` for `state.typeaheadActors` compatibility (or migrate state to `TypeaheadResult`) 42 + - [x] Update jump-to-profile dialog to use `TypeaheadCubit` + `TypeaheadTextField` 43 + - [x] Update list member add screen to use `TypeaheadRepository` 44 + - [x] Update starter pack member search to use `TypeaheadRepository` 45 + - [x] Unit tests: `SearchBloc` typeahead delegates to `TypeaheadRepository` 46 + - [x] Widget tests: search typeahead renders results from configured provider 47 + 48 + ## M5.1 - Runtime Provider Propagation 49 + 50 + - [x] Ensure `TypeaheadRepository` resolves provider dynamically so existing consumers pick up `SettingsCubit.typeaheadProvider` changes without app/session rebuild 51 + - [x] Wire authenticated app-scope `TypeaheadRepository` to `SettingsCubit` via resolver callback 52 + - [x] Unit test: same repository instance switches between community and Bluesky backends when provider changes at runtime
+26 -3
lib/features/auth/presentation/login_screen.dart
··· 4 4 import 'package:flutter_svg/flutter_svg.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 8 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 9 + import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; 7 10 8 11 class LoginScreen extends StatefulWidget { 9 - const LoginScreen({super.key}); 12 + const LoginScreen({this.typeaheadRepository, super.key}); 13 + 14 + final TypeaheadRepository? typeaheadRepository; 10 15 11 16 @override 12 17 State<LoginScreen> createState() => _LoginScreenState(); ··· 17 22 final _appPasswordController = TextEditingController(); 18 23 final _formKey = GlobalKey<FormState>(); 19 24 bool _showDebugForm = false; 25 + late final TypeaheadRepository _typeaheadRepository; 26 + 27 + @override 28 + void initState() { 29 + super.initState(); 30 + _typeaheadRepository = 31 + widget.typeaheadRepository ?? TypeaheadRepository(provider: TypeaheadRepository.communityProvider); 32 + } 20 33 21 34 @override 22 35 void dispose() { ··· 47 60 return _formKey.currentState?.validate() ?? false; 48 61 } 49 62 63 + void _onTypeaheadSelected(TypeaheadResult result) { 64 + _handleController.text = result.handle; 65 + _onOAuthLogin(); 66 + } 67 + 50 68 @override 51 69 Widget build(BuildContext context) { 52 70 final theme = Theme.of(context); ··· 93 111 style: theme.textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), 94 112 ), 95 113 const SizedBox(height: 32), 96 - TextFormField( 114 + TypeaheadTextField( 97 115 controller: _handleController, 116 + repository: _typeaheadRepository, 117 + onSelected: _onTypeaheadSelected, 118 + minChars: 2, 119 + debounceMs: 300, 120 + limit: 8, 98 121 decoration: const InputDecoration( 99 122 labelText: 'Handle or DID', 100 123 hintText: 'username.bsky.social or did:plc:...', 101 124 prefixIcon: Icon(Icons.person_outline), 102 125 border: OutlineInputBorder(), 103 126 ), 104 - textInputAction: TextInputAction.next, 105 127 autocorrect: false, 128 + textInputAction: TextInputAction.next, 106 129 validator: (value) { 107 130 if (value == null || value.trim().isEmpty) { 108 131 return 'Enter your BlueSky handle or DID';
+7 -3
lib/features/lists/presentation/list_members_screen.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 6 - import 'package:lazurite/features/lists/data/list_repository.dart'; 6 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 7 7 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 8 8 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 9 9 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 51 51 setState(() => _isSearching = true); 52 52 53 53 try { 54 - final results = await context.read<ListRepository>().searchActorsTypeahead(query: query.trim(), limit: 10); 55 - if (mounted) setState(() => _searchResults = results); 54 + final results = await context.read<TypeaheadRepository>().search(query: query.trim(), limit: 10); 55 + if (mounted) { 56 + setState(() { 57 + _searchResults = results.map((result) => result.toProfileViewBasic()).toList(growable: false); 58 + }); 59 + } 56 60 } catch (_) { 57 61 if (mounted) setState(() => _searchResults = []); 58 62 } finally {
+14 -6
lib/features/search/bloc/search_bloc.dart
··· 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 9 import 'package:lazurite/features/search/data/search_repository.dart'; 10 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 10 11 11 12 part 'search_state.dart'; 12 13 13 14 class SearchBloc extends Bloc<SearchEvent, SearchState> { 14 - SearchBloc({required SearchRepository searchRepository, required AppDatabase database, required String accountDid}) 15 - : _searchRepository = searchRepository, 16 - _database = database, 17 - _accountDid = accountDid, 18 - super(const SearchState.initial()) { 15 + SearchBloc({ 16 + required SearchRepository searchRepository, 17 + required TypeaheadRepository typeaheadRepository, 18 + required AppDatabase database, 19 + required String accountDid, 20 + }) : _searchRepository = searchRepository, 21 + _typeaheadRepository = typeaheadRepository, 22 + _database = database, 23 + _accountDid = accountDid, 24 + super(const SearchState.initial()) { 19 25 on<QuerySubmitted>(_onQuerySubmitted); 20 26 on<SearchTabChanged>(_onSearchTabChanged); 21 27 on<SearchSortChanged>(_onSearchSortChanged); ··· 31 37 } 32 38 33 39 final SearchRepository _searchRepository; 40 + final TypeaheadRepository _typeaheadRepository; 34 41 final AppDatabase _database; 35 42 final String _accountDid; 36 43 Timer? _debounceTimer; ··· 240 247 241 248 Future<void> _onTypeaheadResultsLoaded(TypeaheadResultsLoaded event, Emitter<SearchState> emit) async { 242 249 try { 243 - final actors = await _searchRepository.searchActorsTypeahead(query: event.query, limit: 5); 250 + final results = await _typeaheadRepository.search(query: event.query, limit: 5); 251 + final actors = results.map((result) => result.toProfileViewBasic()).toList(growable: false); 244 252 emit(state.copyWith(typeaheadActors: actors)); 245 253 } catch (_) { 246 254 emit(state.copyWith(typeaheadActors: []));
+61 -153
lib/features/search/presentation/search_screen.dart
··· 18 18 import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; 19 19 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 20 20 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 21 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 22 + import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; 21 23 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 22 24 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 23 25 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; ··· 114 116 115 117 void _openJumpToProfileDialog() { 116 118 final controller = TextEditingController(); 117 - final searchBloc = context.read<SearchBloc>(); 119 + final typeaheadRepository = context.read<TypeaheadRepository>(); 118 120 119 121 showDialog<void>( 120 122 context: context, 121 123 builder: (dialogContext) { 122 - return BlocProvider.value( 123 - value: searchBloc, 124 - child: StatefulBuilder( 125 - builder: (context, setDialogState) { 126 - void submitHandle([String? value]) { 127 - final rawValue = (value ?? controller.text).trim(); 128 - final handle = rawValue.startsWith('@') ? rawValue.substring(1).trim() : rawValue; 129 - if (handle.isEmpty) { 130 - return; 131 - } 132 - 133 - searchBloc.add(const TypeaheadRequested(query: '')); 134 - Navigator.of(dialogContext).pop(); 135 - WidgetsBinding.instance.addPostFrameCallback((_) { 136 - if (mounted) { 137 - navigateToProfile(this.context, handle); 138 - } 139 - }); 124 + return StatefulBuilder( 125 + builder: (context, setDialogState) { 126 + void submitHandle([String? value]) { 127 + final rawValue = (value ?? controller.text).trim(); 128 + final handle = rawValue.startsWith('@') ? rawValue.substring(1).trim() : rawValue; 129 + if (handle.isEmpty) { 130 + return; 140 131 } 141 132 142 - return ConfirmationDialog( 143 - title: const Text('Jump to profile'), 144 - content: SizedBox( 145 - width: 420, 146 - child: BlocBuilder<SearchBloc, SearchState>( 147 - builder: (context, state) { 148 - final hasResults = state.typeaheadActors.isNotEmpty; 149 - final showTypingHint = controller.text.trim().length <= 3; 150 - return Column( 151 - mainAxisSize: MainAxisSize.min, 152 - children: [ 153 - TextField( 154 - controller: controller, 155 - autofocus: true, 156 - autocorrect: false, 157 - textInputAction: TextInputAction.search, 158 - decoration: const InputDecoration( 159 - labelText: 'Handle', 160 - hintText: 'alice.bsky.social', 161 - prefixText: '@', 162 - border: OutlineInputBorder(), 163 - ), 164 - onChanged: (value) { 165 - setDialogState(() {}); 166 - searchBloc.add(TypeaheadRequested(query: value.isEmpty ? '' : '@$value')); 167 - }, 168 - onSubmitted: submitHandle, 169 - ), 170 - const SizedBox(height: 12), 171 - if (showTypingHint) 172 - Align( 173 - alignment: Alignment.topLeft, 174 - child: Text('Start typing to search handles.', style: context.textTheme.bodySmall), 175 - ), 176 - AnimatedSize( 177 - duration: const Duration(milliseconds: 180), 178 - curve: Curves.easeOutCubic, 179 - alignment: Alignment.topCenter, 180 - child: hasResults 181 - ? Padding( 182 - padding: const EdgeInsets.only(top: 12), 183 - child: SizedBox( 184 - height: 220, 185 - child: ListView.builder( 186 - shrinkWrap: true, 187 - itemCount: state.typeaheadActors.length, 188 - itemBuilder: (context, index) { 189 - final actor = state.typeaheadActors[index]; 190 - return _ActorListTile( 191 - actor: actor, 192 - onTap: () { 193 - controller.text = actor.handle; 194 - submitHandle(actor.did); 195 - }, 196 - ); 197 - }, 198 - ), 199 - ), 200 - ) 201 - : const SizedBox.shrink(), 202 - ), 203 - ], 204 - ); 205 - }, 206 - ), 133 + Navigator.of(dialogContext).pop(); 134 + WidgetsBinding.instance.addPostFrameCallback((_) { 135 + if (mounted) { 136 + navigateToProfile(this.context, handle); 137 + } 138 + }); 139 + } 140 + 141 + final showTypingHint = controller.text.trim().length <= 3; 142 + return ConfirmationDialog( 143 + title: const Text('Jump to profile'), 144 + content: SizedBox( 145 + width: 420, 146 + child: Column( 147 + mainAxisSize: MainAxisSize.min, 148 + children: [ 149 + TypeaheadTextField( 150 + controller: controller, 151 + repository: typeaheadRepository, 152 + onSelected: (result) { 153 + controller.text = result.handle; 154 + submitHandle(result.did); 155 + }, 156 + minChars: 2, 157 + debounceMs: 300, 158 + limit: 8, 159 + autocorrect: false, 160 + textInputAction: TextInputAction.search, 161 + onFieldSubmitted: submitHandle, 162 + onChanged: (_) => setDialogState(() {}), 163 + decoration: const InputDecoration( 164 + labelText: 'Handle', 165 + hintText: 'alice.bsky.social', 166 + prefixText: '@', 167 + border: OutlineInputBorder(), 168 + ), 169 + ), 170 + const SizedBox(height: 12), 171 + if (showTypingHint) 172 + Align( 173 + alignment: Alignment.topLeft, 174 + child: Text('Start typing to search handles.', style: context.textTheme.bodySmall), 175 + ), 176 + ], 207 177 ), 208 - confirmLabel: 'Open', 209 - confirmEnabled: controller.text.trim().isNotEmpty, 210 - onCancel: () { 211 - searchBloc.add(const TypeaheadRequested(query: '')); 212 - Navigator.of(dialogContext).pop(); 213 - }, 214 - onConfirm: submitHandle, 215 - ); 216 - }, 217 - ), 178 + ), 179 + confirmLabel: 'Open', 180 + confirmEnabled: controller.text.trim().isNotEmpty, 181 + onCancel: () => Navigator.of(dialogContext).pop(), 182 + onConfirm: submitHandle, 183 + ); 184 + }, 218 185 ); 219 186 }, 220 187 ); ··· 835 802 836 803 void _navigateToProfile(BuildContext context, String did) { 837 804 navigateToProfile(context, did); 838 - } 839 - } 840 - 841 - class _ActorListTile extends StatelessWidget { 842 - const _ActorListTile({required this.actor, required this.onTap}); 843 - 844 - final ProfileViewBasic actor; 845 - final VoidCallback onTap; 846 - 847 - @override 848 - Widget build(BuildContext context) { 849 - final moderationService = maybeModerationService(context); 850 - final profileUi = 851 - moderationService?.profileBasicUi(actor, bsky_moderation.ModerationBehaviorContext.profileList) ?? 852 - const bsky_moderation.ModerationUI(); 853 - final avatarUi = 854 - moderationService?.profileBasicUi(actor, bsky_moderation.ModerationBehaviorContext.avatar) ?? 855 - const bsky_moderation.ModerationUI(); 856 - 857 - return InkWell( 858 - onTap: onTap, 859 - child: Container( 860 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 861 - child: Row( 862 - children: [ 863 - ProfileAvatar( 864 - size: 40, 865 - moderationUi: avatarUi, 866 - imageUrl: actor.avatar, 867 - fallbackText: actor.displayName ?? actor.handle, 868 - shape: BoxShape.circle, 869 - placeholderTextStyle: context.textTheme.labelMedium, 870 - ), 871 - const SizedBox(width: 12), 872 - Expanded( 873 - child: Column( 874 - crossAxisAlignment: CrossAxisAlignment.start, 875 - children: [ 876 - Text( 877 - actor.displayName ?? actor.handle, 878 - style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), 879 - maxLines: 1, 880 - overflow: TextOverflow.ellipsis, 881 - ), 882 - Text( 883 - '@${actor.handle}', 884 - style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), 885 - ), 886 - if (profileUi.alert || profileUi.inform) ...[ 887 - const SizedBox(height: 8), 888 - ModerationBadgeRow(ui: profileUi), 889 - ], 890 - ], 891 - ), 892 - ), 893 - ], 894 - ), 895 - ), 896 - ); 897 805 } 898 806 } 899 807
+7 -3
lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:go_router/go_router.dart'; 7 - import 'package:lazurite/features/lists/data/list_repository.dart'; 8 7 import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 9 8 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 9 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 10 10 import 'package:lazurite/core/theme/theme_extensions.dart'; 11 11 import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 12 12 ··· 59 59 setState(() => _isSearching = true); 60 60 61 61 try { 62 - final results = await context.read<ListRepository>().searchActorsTypeahead(query: query.trim(), limit: 10); 63 - if (mounted) setState(() => _searchResults = results); 62 + final results = await context.read<TypeaheadRepository>().search(query: query.trim(), limit: 10); 63 + if (mounted) { 64 + setState(() { 65 + _searchResults = results.map((result) => result.toProfileViewBasic()).toList(growable: false); 66 + }); 67 + } 64 68 } catch (_) { 65 69 if (mounted) setState(() => _searchResults = []); 66 70 } finally {
+33 -5
lib/features/typeahead/data/typeahead_repository.dart
··· 10 10 class TypeaheadRepository { 11 11 TypeaheadRepository({ 12 12 dynamic bluesky, 13 - required String provider, 13 + String? provider, 14 + String Function()? providerResolver, 14 15 ModerationService? moderationService, 15 16 http.Client? httpClient, 16 17 }) : _bluesky = bluesky, 17 - _provider = provider.trim().toLowerCase(), 18 + _provider = provider?.trim().toLowerCase(), 19 + _providerResolver = providerResolver, 18 20 _moderationService = moderationService, 19 21 _httpClient = httpClient ?? http.Client() { 20 - if (!_isSupportedProvider(_provider)) { 22 + if (_provider == null && _providerResolver == null) { 23 + throw ArgumentError('Either a static provider or providerResolver must be supplied.'); 24 + } 25 + 26 + if (_provider != null && !_isSupportedProvider(_provider)) { 21 27 throw ArgumentError.value(provider, 'provider', 'Supported providers are "bluesky" and "community".'); 22 28 } 23 29 } ··· 29 35 static const String _communityPath = '/xrpc/app.bsky.actor.searchActorsTypeahead'; 30 36 31 37 final dynamic _bluesky; 32 - final String _provider; 38 + final String? _provider; 39 + final String Function()? _providerResolver; 33 40 final ModerationService? _moderationService; 34 41 final http.Client _httpClient; 35 42 ··· 40 47 } 41 48 42 49 final normalizedLimit = limit.clamp(1, 100); 50 + final provider = _resolveProvider(); 43 51 44 - if (_provider == blueskyProvider) { 52 + if (provider == blueskyProvider) { 45 53 return _searchBluesky(query: normalizedQuery, limit: normalizedLimit); 46 54 } 47 55 ··· 60 68 61 69 return _searchBluesky(query: normalizedQuery, limit: normalizedLimit); 62 70 } 71 + } 72 + 73 + String _resolveProvider() { 74 + final resolver = _providerResolver; 75 + if (resolver == null) { 76 + return _provider!; 77 + } 78 + 79 + final resolvedProvider = resolver.call().trim().toLowerCase(); 80 + if (_isSupportedProvider(resolvedProvider)) { 81 + return resolvedProvider; 82 + } 83 + 84 + if (resolvedProvider.isNotEmpty) { 85 + log.w( 86 + 'TypeaheadRepository: unsupported provider "$resolvedProvider" from resolver; falling back to static/default provider.', 87 + ); 88 + } 89 + 90 + return _provider ?? blueskyProvider; 63 91 } 64 92 65 93 Future<List<TypeaheadResult>> _searchBluesky({required String query, required int limit}) async {
+48 -4
lib/features/typeahead/presentation/typeahead_text_field.dart
··· 15 15 this.debounceMs = 300, 16 16 this.minChars = 2, 17 17 this.limit = 10, 18 + this.validator, 19 + this.textInputAction, 20 + this.onFieldSubmitted, 21 + this.onChanged, 22 + this.enabled = true, 23 + this.autocorrect = false, 24 + this.focusNode, 18 25 super.key, 19 26 }); 20 27 ··· 25 32 final int debounceMs; 26 33 final int minChars; 27 34 final int limit; 35 + final FormFieldValidator<String>? validator; 36 + final TextInputAction? textInputAction; 37 + final ValueChanged<String>? onFieldSubmitted; 38 + final ValueChanged<String>? onChanged; 39 + final bool enabled; 40 + final bool autocorrect; 41 + final FocusNode? focusNode; 28 42 29 43 @override 30 44 State<TypeaheadTextField> createState() => _TypeaheadTextFieldState(); ··· 33 47 class _TypeaheadTextFieldState extends State<TypeaheadTextField> with WidgetsBindingObserver { 34 48 final LayerLink _layerLink = LayerLink(); 35 49 final GlobalKey _fieldKey = GlobalKey(); 36 - final FocusNode _focusNode = FocusNode(); 50 + late FocusNode _focusNode; 51 + late bool _ownsFocusNode; 37 52 final Object _tapRegionGroup = Object(); 38 53 39 54 TypeaheadCubit? _cubit; ··· 43 58 @override 44 59 void initState() { 45 60 super.initState(); 61 + _ownsFocusNode = widget.focusNode == null; 62 + _focusNode = widget.focusNode ?? FocusNode(); 46 63 WidgetsBinding.instance.addObserver(this); 47 64 _focusNode.addListener(_handleFocusChange); 48 65 _createCubit(); ··· 52 69 void didUpdateWidget(TypeaheadTextField oldWidget) { 53 70 super.didUpdateWidget(oldWidget); 54 71 72 + if (oldWidget.focusNode != widget.focusNode) { 73 + _focusNode.removeListener(_handleFocusChange); 74 + if (_ownsFocusNode) { 75 + _focusNode.dispose(); 76 + } 77 + _ownsFocusNode = widget.focusNode == null; 78 + _focusNode = widget.focusNode ?? FocusNode(); 79 + _focusNode.addListener(_handleFocusChange); 80 + } 81 + 55 82 final shouldRecreateCubit = 56 83 oldWidget.repository != widget.repository || 57 84 oldWidget.debounceMs != widget.debounceMs || ··· 64 91 _runQuery(widget.controller.text); 65 92 } 66 93 67 - _overlayEntry?.markNeedsBuild(); 94 + if (_overlayEntry != null) { 95 + WidgetsBinding.instance.addPostFrameCallback((_) { 96 + if (!mounted) { 97 + return; 98 + } 99 + _overlayEntry?.markNeedsBuild(); 100 + }); 101 + } 68 102 } 69 103 70 104 @override ··· 76 110 void dispose() { 77 111 WidgetsBinding.instance.removeObserver(this); 78 112 _focusNode.removeListener(_handleFocusChange); 79 - _focusNode.dispose(); 113 + if (_ownsFocusNode) { 114 + _focusNode.dispose(); 115 + } 80 116 81 117 _removeOverlay(); 82 118 _stateSubscription?.cancel(); ··· 227 263 controller: widget.controller, 228 264 focusNode: _focusNode, 229 265 decoration: widget.decoration, 230 - onChanged: _runQuery, 266 + validator: widget.validator, 267 + textInputAction: widget.textInputAction, 268 + enabled: widget.enabled, 269 + autocorrect: widget.autocorrect, 270 + onFieldSubmitted: widget.onFieldSubmitted, 271 + onChanged: (value) { 272 + _runQuery(value); 273 + widget.onChanged?.call(value); 274 + }, 231 275 ), 232 276 ), 233 277 );
+12
lib/main.dart
··· 49 49 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 50 50 import 'package:lazurite/features/settings/data/video_repository.dart'; 51 51 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 52 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 52 53 53 54 Future<void> main() async { 54 55 WidgetsFlutterBinding.ensureInitialized(); ··· 258 259 SearchRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 259 260 ), 260 261 RepositoryProvider( 262 + create: (context) { 263 + final settingsCubit = context.read<SettingsCubit>(); 264 + return TypeaheadRepository( 265 + bluesky: bluesky, 266 + providerResolver: () => settingsCubit.state.typeaheadProvider, 267 + moderationService: context.read<ModerationService>(), 268 + ); 269 + }, 270 + ), 271 + RepositoryProvider( 261 272 create: (context) => 262 273 ListRepository(bluesky: bluesky, moderationService: context.read<ModerationService>()), 263 274 ), ··· 327 338 BlocProvider( 328 339 create: (context) => SearchBloc( 329 340 searchRepository: context.read<SearchRepository>(), 341 + typeaheadRepository: context.read<TypeaheadRepository>(), 330 342 database: widget.database, 331 343 accountDid: accountDid, 332 344 ),
+58 -1
test/features/auth/presentation/login_screen_test.dart
··· 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 7 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 8 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 9 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 8 10 import 'package:mocktail/mocktail.dart'; 9 11 10 12 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} ··· 19 21 }); 20 22 21 23 Widget buildSubject() { 24 + final typeaheadRepository = _FakeTypeaheadRepository( 25 + searchHandler: ({required String query, int limit = 10}) async => const [], 26 + ); 27 + 22 28 final router = GoRouter( 23 29 routes: [ 24 30 GoRoute( 25 31 path: '/login', 26 - builder: (context, state) => BlocProvider<AuthBloc>.value(value: authBloc, child: const LoginScreen()), 32 + builder: (context, state) => BlocProvider<AuthBloc>.value( 33 + value: authBloc, 34 + child: LoginScreen(typeaheadRepository: typeaheadRepository), 35 + ), 27 36 ), 28 37 GoRoute( 29 38 path: '/terms', ··· 78 87 79 88 expect(find.text('privacy-route'), findsOneWidget); 80 89 }); 90 + 91 + testWidgets('login typeahead shows community results and selecting triggers OAuth login', (tester) async { 92 + final typeaheadRepository = _FakeTypeaheadRepository( 93 + searchHandler: ({required String query, int limit = 10}) async { 94 + if (query == 'ri') { 95 + return const [TypeaheadResult(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam')]; 96 + } 97 + return const []; 98 + }, 99 + ); 100 + 101 + final router = GoRouter( 102 + routes: [ 103 + GoRoute( 104 + path: '/login', 105 + builder: (context, state) => BlocProvider<AuthBloc>.value( 106 + value: authBloc, 107 + child: LoginScreen(typeaheadRepository: typeaheadRepository), 108 + ), 109 + ), 110 + ], 111 + initialLocation: '/login', 112 + ); 113 + 114 + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); 115 + await tester.pumpAndSettle(); 116 + 117 + await tester.enterText(find.byType(TextFormField).first, 'ri'); 118 + await tester.pump(const Duration(milliseconds: 350)); 119 + await tester.pumpAndSettle(); 120 + 121 + expect(find.text('River Tam'), findsOneWidget); 122 + await tester.tap(find.text('River Tam')); 123 + await tester.pumpAndSettle(); 124 + 125 + verify(() => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social'))).called(1); 126 + }); 127 + } 128 + 129 + class _FakeTypeaheadRepository extends TypeaheadRepository { 130 + _FakeTypeaheadRepository({required this.searchHandler}) : super(provider: TypeaheadRepository.communityProvider); 131 + 132 + final Future<List<TypeaheadResult>> Function({required String query, int limit}) searchHandler; 133 + 134 + @override 135 + Future<List<TypeaheadResult>> search({required String query, int limit = 10}) { 136 + return searchHandler(query: query, limit: limit); 137 + } 81 138 }
+18 -3
test/features/lists/presentation/list_members_screen_test.dart
··· 10 10 import 'package:lazurite/features/lists/bloc/list_bloc.dart'; 11 11 import 'package:lazurite/features/lists/data/list_repository.dart'; 12 12 import 'package:lazurite/features/lists/presentation/list_members_screen.dart'; 13 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 14 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 13 15 import 'package:mocktail/mocktail.dart'; 14 16 15 17 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 16 18 17 19 class MockListRepository extends Mock implements ListRepository {} 18 20 21 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 22 + 19 23 void main() { 20 24 late MockAuthBloc authBloc; 21 25 late MockListRepository listRepository; 26 + late MockTypeaheadRepository typeaheadRepository; 22 27 23 28 const tokens = AuthTokens( 24 29 accessToken: 'access', ··· 46 51 setUp(() { 47 52 authBloc = MockAuthBloc(); 48 53 listRepository = MockListRepository(); 54 + typeaheadRepository = MockTypeaheadRepository(); 49 55 50 56 when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 51 57 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); ··· 57 63 limit: any(named: 'limit'), 58 64 ), 59 65 ).thenAnswer((_) async => ListDetailResult(list: curationList, items: [member], cursor: null)); 66 + when( 67 + () => typeaheadRepository.search( 68 + query: any(named: 'query'), 69 + limit: any(named: 'limit'), 70 + ), 71 + ).thenAnswer((_) async => const []); 60 72 }); 61 73 62 74 Widget buildSubject() { 63 75 return MultiBlocProvider( 64 76 providers: [BlocProvider<AuthBloc>.value(value: authBloc)], 65 77 child: MultiRepositoryProvider( 66 - providers: [RepositoryProvider<ListRepository>.value(value: listRepository)], 78 + providers: [ 79 + RepositoryProvider<ListRepository>.value(value: listRepository), 80 + RepositoryProvider<TypeaheadRepository>.value(value: typeaheadRepository), 81 + ], 67 82 child: MaterialApp( 68 83 home: BlocProvider( 69 84 create: (_) => ListBloc(listRepository: listRepository)..add(ListRequested(listUri: listUri)), ··· 130 145 const searchResult = ProfileViewBasic(did: 'did:plc:new', handle: 'newuser.bsky.social', displayName: 'New User'); 131 146 132 147 when( 133 - () => listRepository.searchActorsTypeahead( 148 + () => typeaheadRepository.search( 134 149 query: 'newuser', 135 150 limit: any(named: 'limit'), 136 151 ), 137 - ).thenAnswer((_) async => [searchResult]); 152 + ).thenAnswer((_) async => [TypeaheadResult.fromProfileViewBasic(searchResult)]); 138 153 139 154 await tester.pumpWidget(buildSubject()); 140 155 await tester.pumpAndSettle();
+46 -2
test/features/search/bloc/search_bloc_test.dart
··· 7 7 import 'package:lazurite/core/database/app_database.dart'; 8 8 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 9 9 import 'package:lazurite/features/search/data/search_repository.dart'; 10 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 11 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 10 12 import 'package:mocktail/mocktail.dart'; 11 13 12 14 class MockSearchRepository extends Mock implements SearchRepository {} 13 15 16 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 17 + 14 18 class MockAppDatabase extends Mock implements AppDatabase {} 15 19 16 20 void main() { 17 21 late MockSearchRepository mockRepository; 22 + late MockTypeaheadRepository mockTypeaheadRepository; 18 23 late MockAppDatabase mockDatabase; 19 24 20 25 final samplePost = PostView( ··· 59 64 60 65 setUp(() { 61 66 mockRepository = MockSearchRepository(); 67 + mockTypeaheadRepository = MockTypeaheadRepository(); 62 68 mockDatabase = MockAppDatabase(); 63 69 64 70 when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); ··· 98 104 limit: any(named: 'limit'), 99 105 ), 100 106 ).thenAnswer((_) async => SearchFeedsResult(feeds: [])); 107 + when( 108 + () => mockTypeaheadRepository.search( 109 + query: any(named: 'query'), 110 + limit: any(named: 'limit'), 111 + ), 112 + ).thenAnswer((_) async => const []); 101 113 }); 102 114 103 - SearchBloc buildBloc() => 104 - SearchBloc(searchRepository: mockRepository, database: mockDatabase, accountDid: 'did:plc:test'); 115 + SearchBloc buildBloc() => SearchBloc( 116 + searchRepository: mockRepository, 117 + typeaheadRepository: mockTypeaheadRepository, 118 + database: mockDatabase, 119 + accountDid: 'did:plc:test', 120 + ); 105 121 106 122 group('SearchTab enum', () { 107 123 test('has posts, actors, feeds, and starterPacks values', () { ··· 459 475 predicate<SearchState>((s) => s.isLoadingMore), 460 476 predicate<SearchState>((s) => !s.isLoadingMore && s.feeds.length == 1), 461 477 ], 478 + ); 479 + }); 480 + 481 + group('Typeahead delegation', () { 482 + blocTest<SearchBloc, SearchState>( 483 + 'TypeaheadRequested delegates to TypeaheadRepository and maps results to ProfileViewBasic', 484 + build: buildBloc, 485 + skip: 1, 486 + setUp: () { 487 + when( 488 + () => mockTypeaheadRepository.search( 489 + query: 'river', 490 + limit: any(named: 'limit'), 491 + ), 492 + ).thenAnswer( 493 + (_) async => const [TypeaheadResult(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River')], 494 + ); 495 + }, 496 + act: (bloc) => bloc.add(const TypeaheadRequested(query: '@river')), 497 + wait: const Duration(milliseconds: 350), 498 + expect: () => [ 499 + isA<SearchState>() 500 + .having((state) => state.typeaheadActors.length, 'typeahead actors', 1) 501 + .having((state) => state.typeaheadActors.first.handle, 'first handle', 'river.bsky.social'), 502 + ], 503 + verify: (_) { 504 + verify(() => mockTypeaheadRepository.search(query: 'river', limit: 5)).called(1); 505 + }, 462 506 ); 463 507 }); 464 508
+54 -34
test/features/search/presentation/search_screen_test.dart
··· 13 13 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 14 14 import 'package:lazurite/features/search/data/search_repository.dart'; 15 15 import 'package:lazurite/features/search/presentation/search_screen.dart'; 16 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 17 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 16 18 import 'package:mocktail/mocktail.dart'; 17 19 18 20 class MockSearchRepository extends Mock implements SearchRepository {} 19 21 22 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 23 + 20 24 class MockAppDatabase extends Mock implements AppDatabase {} 21 25 22 26 class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} ··· 30 34 31 35 group('SearchScreen', () { 32 36 late MockSearchRepository mockSearchRepository; 37 + late MockTypeaheadRepository mockTypeaheadRepository; 33 38 late MockAppDatabase mockDatabase; 34 39 late MockConnectivityCubit connectivityCubit; 35 40 late MockFeedPreferencesCubit feedPreferencesCubit; 36 41 37 42 setUp(() { 38 43 mockSearchRepository = MockSearchRepository(); 44 + mockTypeaheadRepository = MockTypeaheadRepository(); 39 45 mockDatabase = MockAppDatabase(); 40 46 connectivityCubit = MockConnectivityCubit(); 41 47 feedPreferencesCubit = MockFeedPreferencesCubit(); ··· 75 81 ), 76 82 ).thenAnswer((_) async => SearchActorsResult(actors: [])); 77 83 when( 78 - () => mockSearchRepository.searchActorsTypeahead( 84 + () => mockTypeaheadRepository.search( 79 85 query: any(named: 'query'), 80 86 limit: any(named: 'limit'), 81 87 ), 82 - ).thenAnswer((_) async => []); 88 + ).thenAnswer((_) async => const []); 83 89 when( 84 90 () => mockSearchRepository.searchStarterPacks( 85 91 query: any(named: 'query'), ··· 100 106 return MaterialApp( 101 107 home: MultiBlocProvider( 102 108 providers: [ 109 + RepositoryProvider<TypeaheadRepository>.value(value: mockTypeaheadRepository), 103 110 BlocProvider<SearchBloc>( 104 111 create: (_) => SearchBloc( 105 112 searchRepository: mockSearchRepository, 113 + typeaheadRepository: mockTypeaheadRepository, 106 114 database: mockDatabase, 107 115 accountDid: 'did:plc:test', 108 116 ), ··· 120 128 routes: [ 121 129 GoRoute( 122 130 path: '/', 123 - builder: (context, state) => BlocProvider<SearchBloc>( 124 - create: (_) => SearchBloc( 125 - searchRepository: mockSearchRepository, 126 - database: mockDatabase, 127 - accountDid: 'did:plc:test', 128 - ), 129 - child: MultiBlocProvider( 130 - providers: [ 131 - BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 132 - BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 133 - ], 134 - child: const SearchScreen(), 131 + builder: (context, state) => RepositoryProvider<TypeaheadRepository>.value( 132 + value: mockTypeaheadRepository, 133 + child: BlocProvider<SearchBloc>( 134 + create: (_) => SearchBloc( 135 + searchRepository: mockSearchRepository, 136 + typeaheadRepository: mockTypeaheadRepository, 137 + database: mockDatabase, 138 + accountDid: 'did:plc:test', 139 + ), 140 + child: MultiBlocProvider( 141 + providers: [ 142 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 143 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 144 + ], 145 + child: const SearchScreen(), 146 + ), 135 147 ), 136 148 ), 137 149 ), ··· 225 237 ).thenAnswer((_) async => SearchActorsResult(actors: [])); 226 238 227 239 when( 228 - () => mockSearchRepository.searchActorsTypeahead( 240 + () => mockTypeaheadRepository.search( 229 241 query: any(named: 'query'), 230 242 limit: any(named: 'limit'), 231 243 ), ··· 244 256 MaterialApp( 245 257 home: MultiBlocProvider( 246 258 providers: [ 259 + RepositoryProvider<TypeaheadRepository>.value(value: mockTypeaheadRepository), 247 260 BlocProvider<SearchBloc>( 248 261 create: (_) => SearchBloc( 249 262 searchRepository: mockSearchRepository, 263 + typeaheadRepository: mockTypeaheadRepository, 250 264 database: mockDatabase, 251 265 accountDid: 'did:plc:test', 252 266 ), ··· 288 302 MaterialApp( 289 303 home: MultiBlocProvider( 290 304 providers: [ 305 + RepositoryProvider<TypeaheadRepository>.value(value: mockTypeaheadRepository), 291 306 BlocProvider<SearchBloc>( 292 307 create: (_) => SearchBloc( 293 308 searchRepository: mockSearchRepository, 309 + typeaheadRepository: mockTypeaheadRepository, 294 310 database: mockDatabase, 295 311 accountDid: 'did:plc:test', 296 312 ), ··· 330 346 331 347 expect(find.text('Start typing to search handles.'), findsOneWidget); 332 348 333 - await tester.enterText(find.byType(TextField).last, 'rive'); 349 + await tester.enterText(find.byType(TextFormField), 'rive'); 334 350 await tester.pumpAndSettle(); 335 351 336 352 expect(find.text('Start typing to search handles.'), findsNothing); ··· 338 354 339 355 testWidgets('jump to profile dialog shows typeahead suggestions and navigates on selection', (tester) async { 340 356 when( 341 - () => mockSearchRepository.searchActorsTypeahead( 357 + () => mockTypeaheadRepository.search( 342 358 query: any(named: 'query'), 343 359 limit: any(named: 'limit'), 344 360 ), 345 361 ).thenAnswer( 346 362 (_) async => const [ 347 - ProfileViewBasic(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 363 + TypeaheadResult(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 348 364 ], 349 365 ); 350 366 ··· 354 370 await tester.tap(find.text('Jump to profile')); 355 371 await tester.pumpAndSettle(); 356 372 357 - await tester.enterText(find.byType(TextField).last, 'river'); 373 + await tester.enterText(find.byType(TextFormField), 'river'); 358 374 await tester.pump(const Duration(milliseconds: 350)); 359 375 await tester.pumpAndSettle(); 360 376 ··· 368 384 369 385 testWidgets('main search input with @ does not show autocomplete results', (tester) async { 370 386 when( 371 - () => mockSearchRepository.searchActorsTypeahead( 387 + () => mockTypeaheadRepository.search( 372 388 query: any(named: 'query'), 373 389 limit: any(named: 'limit'), 374 390 ), 375 391 ).thenAnswer( 376 392 (_) async => const [ 377 - ProfileViewBasic(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 393 + TypeaheadResult(did: 'did:plc:river', handle: 'river.bsky.social', displayName: 'River Tam'), 378 394 ], 379 395 ); 380 396 ··· 395 411 await tester.tap(find.text('Jump to profile')); 396 412 await tester.pumpAndSettle(); 397 413 398 - await tester.enterText(find.byType(TextField).last, 'custom.bsky.social'); 414 + await tester.enterText(find.byType(TextFormField), 'custom.bsky.social'); 399 415 await tester.testTextInput.receiveAction(TextInputAction.search); 400 416 await tester.pumpAndSettle(); 401 417 ··· 620 636 routes: [ 621 637 GoRoute( 622 638 path: '/', 623 - builder: (context, state) => BlocProvider<SearchBloc>( 624 - create: (_) => SearchBloc( 625 - searchRepository: mockSearchRepository, 626 - database: mockDatabase, 627 - accountDid: 'did:plc:test', 628 - ), 629 - child: MultiBlocProvider( 630 - providers: [ 631 - BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 632 - BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 633 - ], 634 - child: const SearchScreen(), 639 + builder: (context, state) => RepositoryProvider<TypeaheadRepository>.value( 640 + value: mockTypeaheadRepository, 641 + child: BlocProvider<SearchBloc>( 642 + create: (_) => SearchBloc( 643 + searchRepository: mockSearchRepository, 644 + typeaheadRepository: mockTypeaheadRepository, 645 + database: mockDatabase, 646 + accountDid: 'did:plc:test', 647 + ), 648 + child: MultiBlocProvider( 649 + providers: [ 650 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 651 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 652 + ], 653 + child: const SearchScreen(), 654 + ), 635 655 ), 636 656 ), 637 657 ),
+14 -7
test/features/starter_packs/presentation/create_edit_starter_pack_screen_test.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:flutter_test/flutter_test.dart'; 7 - import 'package:lazurite/features/lists/data/list_repository.dart'; 8 7 import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; 9 8 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 10 9 import 'package:lazurite/features/starter_packs/presentation/create_edit_starter_pack_screen.dart'; 10 + import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 11 + import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 11 12 import 'package:mocktail/mocktail.dart'; 12 13 13 14 class MockStarterPackRepository extends Mock implements StarterPackRepository {} 14 15 15 - class MockListRepository extends Mock implements ListRepository {} 16 + class MockTypeaheadRepository extends Mock implements TypeaheadRepository {} 16 17 17 18 class MockStarterPackBloc extends Mock implements StarterPackBloc {} 18 19 19 20 void main() { 20 21 late MockStarterPackRepository mockRepo; 21 - late MockListRepository mockListRepo; 22 + late MockTypeaheadRepository mockTypeaheadRepo; 22 23 23 24 const userDid = 'did:plc:user'; 24 25 final packUri = AtUri.parse('at://did:plc:user/app.bsky.graph.starterpack/pack-1'); ··· 29 30 30 31 setUp(() { 31 32 mockRepo = MockStarterPackRepository(); 32 - mockListRepo = MockListRepository(); 33 + mockTypeaheadRepo = MockTypeaheadRepository(); 34 + when( 35 + () => mockTypeaheadRepo.search( 36 + query: any(named: 'query'), 37 + limit: any(named: 'limit'), 38 + ), 39 + ).thenAnswer((_) async => const []); 33 40 }); 34 41 35 42 Widget buildSubject() { 36 43 return MultiRepositoryProvider( 37 44 providers: [ 38 45 RepositoryProvider<StarterPackRepository>.value(value: mockRepo), 39 - RepositoryProvider<ListRepository>.value(value: mockListRepo), 46 + RepositoryProvider<TypeaheadRepository>.value(value: mockTypeaheadRepo), 40 47 RepositoryProvider.value(value: userDid), 41 48 ], 42 49 child: BlocProvider( ··· 81 88 const profile = ProfileViewBasic(did: 'did:plc:member', handle: 'member.bsky.social', displayName: 'Alice'); 82 89 83 90 when( 84 - () => mockListRepo.searchActorsTypeahead( 91 + () => mockTypeaheadRepo.search( 85 92 query: any(named: 'query'), 86 93 limit: any(named: 'limit'), 87 94 ), 88 - ).thenAnswer((_) async => [profile]); 95 + ).thenAnswer((_) async => [TypeaheadResult.fromProfileViewBasic(profile)]); 89 96 90 97 await tester.pumpWidget(buildSubject()); 91 98
+41
test/features/typeahead/data/typeahead_repository_test.dart
··· 139 139 expect(actorService.lastLimit, 8); 140 140 }); 141 141 142 + test('provider resolver picks up runtime provider changes without recreating repository', () async { 143 + var selectedProvider = TypeaheadRepository.communityProvider; 144 + var communityCalls = 0; 145 + 146 + final actorService = _FakeActorService() 147 + ..searchActorsResult = const _FakeActorsData( 148 + actors: [ProfileViewBasic(did: 'did:plc:bluesky', handle: 'bluesky.bsky.social')], 149 + ); 150 + final client = _CallbackClient((_) async { 151 + communityCalls += 1; 152 + return http.Response( 153 + jsonEncode({ 154 + 'actors': [ 155 + {'did': 'did:plc:community', 'handle': 'community.bsky.social'}, 156 + ], 157 + }), 158 + 200, 159 + ); 160 + }); 161 + 162 + final repository = TypeaheadRepository( 163 + bluesky: _FakeBlueskyClient(actor: actorService), 164 + providerResolver: () => selectedProvider, 165 + moderationService: moderationService, 166 + httpClient: client, 167 + ); 168 + 169 + final communityResults = await repository.search(query: 'first', limit: 3); 170 + expect(communityResults.map((actor) => actor.did).toList(), ['did:plc:community']); 171 + expect(communityCalls, 1); 172 + expect(actorService.lastQuery, isNull); 173 + 174 + selectedProvider = TypeaheadRepository.blueskyProvider; 175 + 176 + final blueskyResults = await repository.search(query: 'second', limit: 4); 177 + expect(blueskyResults.map((actor) => actor.did).toList(), ['did:plc:bluesky']); 178 + expect(communityCalls, 1); 179 + expect(actorService.lastQuery, 'second'); 180 + expect(actorService.lastLimit, 4); 181 + }); 182 + 142 183 test('community fallback does not trigger when no Bluesky session/client exists', () async { 143 184 final client = _CallbackClient((_) async => throw const SocketException('no route to host')); 144 185