[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: typeahead search actors

+506 -42
+14
lib/src/core/design_system/components/molecules/input_field.dart
··· 7 7 final List<Widget>? leadingWidgets; 8 8 final List<Widget>? actionWidgets; 9 9 final VoidCallback? onSendMessage; 10 + final ValueChanged<String>? onSubmitted; 11 + final TextInputAction? textInputAction; 10 12 11 13 const InputField._({ 12 14 required this.controller, ··· 14 16 required this.leadingWidgets, 15 17 required this.actionWidgets, 16 18 required this.onSendMessage, 19 + required this.onSubmitted, 20 + required this.textInputAction, 17 21 super.key, 18 22 }); 19 23 ··· 30 34 leadingWidgets: leadingWidgets, 31 35 actionWidgets: actionWidgets, 32 36 onSendMessage: null, 37 + onSubmitted: null, 38 + textInputAction: null, 33 39 ); 34 40 35 41 const InputField.chat({ ··· 45 51 leadingWidgets: leadingWidgets, 46 52 onSendMessage: onSendMessage, 47 53 actionWidgets: null, 54 + onSubmitted: null, 55 + textInputAction: null, 48 56 ); 49 57 50 58 const InputField.search({ ··· 53 61 String hintText = '', 54 62 List<Widget>? leadingWidgets, 55 63 List<Widget>? actionWidgets, 64 + ValueChanged<String>? onSubmitted, 65 + TextInputAction? textInputAction, 56 66 }) : this._( 57 67 key: key, 58 68 controller: controller, ··· 60 70 leadingWidgets: leadingWidgets, 61 71 actionWidgets: actionWidgets, 62 72 onSendMessage: null, 73 + onSubmitted: onSubmitted, 74 + textInputAction: textInputAction, 63 75 ); 64 76 65 77 @override ··· 94 106 95 107 return TextField( 96 108 controller: controller, 109 + onSubmitted: onSubmitted, 110 + textInputAction: textInputAction, 97 111 decoration: InputDecoration( 98 112 hintText: hintText, 99 113 prefixIcon: leading,
+23
lib/src/core/network/atproto/data/models/actor_models.dart
··· 113 113 }; 114 114 } 115 115 116 + /// Response wrapper for actor typeahead suggestions. 117 + class SearchActorsTypeaheadResponse { 118 + SearchActorsTypeaheadResponse({required this.actors}); 119 + 120 + /// Create a [SearchActorsTypeaheadResponse] from JSON. 121 + factory SearchActorsTypeaheadResponse.fromJson(Map<String, dynamic> json) { 122 + final actorsJson = json['actors'] as List<dynamic>? ?? <dynamic>[]; 123 + return SearchActorsTypeaheadResponse( 124 + actors: actorsJson 125 + .map((e) => ProfileViewBasic.fromJson(e as Map<String, dynamic>)) 126 + .toList(), 127 + ); 128 + } 129 + 130 + /// List of returned actor suggestions. 131 + final List<ProfileViewBasic> actors; 132 + 133 + /// Convert the object back to JSON. 134 + Map<String, dynamic> toJson() => { 135 + 'actors': actors.map((e) => e.toJson()).toList(), 136 + }; 137 + } 138 + 116 139 @freezed 117 140 abstract class ProfileViewDetailed with _$ProfileViewDetailed { 118 141 @JsonSerializable(explicitToJson: true)
+9
lib/src/core/network/atproto/data/repositories/actor_repository.dart
··· 23 23 /// [query] The search query. 24 24 Future<SearchActorsResponse> searchActors(String query, {String? cursor}); 25 25 26 + /// Search actor suggestions by query prefix. 27 + /// 28 + /// [query] The search query prefix. 29 + /// [limit] Maximum number of suggestions to return. 30 + Future<SearchActorsTypeaheadResponse> searchActorsTypeahead( 31 + String query, { 32 + int limit = 10, 33 + }); 34 + 26 35 /// Update the user's profile 27 36 /// 28 37 /// [displayName] The new display name
+34
lib/src/core/network/atproto/data/repositories/actor_repository_impl.dart
··· 117 117 } 118 118 119 119 @override 120 + Future<SearchActorsTypeaheadResponse> searchActorsTypeahead( 121 + String query, { 122 + int limit = 10, 123 + }) async { 124 + _logger.d('Searching actor typeahead with query: $query, limit: $limit'); 125 + return _client.executeWithRetry(() async { 126 + final atproto = _client.authRepository.atproto; 127 + if (atproto == null) { 128 + _logger.e('AtProto not initialized'); 129 + throw Exception('AtProto not initialized'); 130 + } 131 + 132 + final clampedLimit = limit.clamp(1, 100); 133 + final result = await atproto.get( 134 + NSID.parse('so.sprk.actor.searchActorsTypeahead'), 135 + parameters: { 136 + 'q': query, 137 + 'limit': clampedLimit.toString(), 138 + }, 139 + headers: {'atproto-proxy': _client.sprkDid}, 140 + to: (jsonMap) => jsonMap, 141 + adaptor: (uint8) => 142 + jsonDecode(utf8.decode(uint8 as List<int>)) as Map<String, dynamic>, 143 + ); 144 + 145 + _logger.d('Actor typeahead search completed successfully'); 146 + 147 + return SearchActorsTypeaheadResponse.fromJson( 148 + result.data as Map<String, dynamic>, 149 + ); 150 + }); 151 + } 152 + 153 + @override 120 154 Future<void> updateProfile({ 121 155 required String displayName, 122 156 required String description,
+80
lib/src/features/search/providers/actor_typeahead_provider.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:get_it/get_it.dart'; 4 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 + import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; 6 + import 'package:spark/src/core/utils/logging/log_service.dart'; 7 + import 'package:spark/src/core/utils/logging/logger.dart'; 8 + import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; 9 + 10 + part 'actor_typeahead_provider.g.dart'; 11 + 12 + @riverpod 13 + class ActorTypeahead extends _$ActorTypeahead { 14 + Timer? _debounce; 15 + final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 16 + 'ActorTypeaheadProvider', 17 + ); 18 + final ActorRepository _actorRepository = GetIt.instance<ActorRepository>(); 19 + 20 + @override 21 + ActorTypeaheadState build() { 22 + ref.onDispose(() { 23 + _debounce?.cancel(); 24 + }); 25 + 26 + return ActorTypeaheadState.initial(); 27 + } 28 + 29 + void updateQuery(String query, {int limit = 10}) { 30 + final trimmedQuery = query.trim(); 31 + 32 + _debounce?.cancel(); 33 + 34 + if (trimmedQuery.isEmpty) { 35 + state = ActorTypeaheadState.initial(); 36 + return; 37 + } 38 + 39 + state = state.copyWith(query: trimmedQuery, isLoading: true, error: null); 40 + 41 + _debounce = Timer(const Duration(milliseconds: 300), () { 42 + unawaited(_searchTypeahead(trimmedQuery, limit: limit)); 43 + }); 44 + } 45 + 46 + Future<void> _searchTypeahead(String query, {int limit = 10}) async { 47 + try { 48 + final response = await _actorRepository.searchActorsTypeahead( 49 + query, 50 + limit: limit, 51 + ); 52 + 53 + if (!ref.mounted || state.query != query) { 54 + return; 55 + } 56 + 57 + state = state.copyWith(results: response.actors, isLoading: false); 58 + } catch (e, stackTrace) { 59 + _logger.e( 60 + 'Failed to fetch actor typeahead', 61 + error: e, 62 + stackTrace: stackTrace, 63 + ); 64 + 65 + if (!ref.mounted || state.query != query) { 66 + return; 67 + } 68 + 69 + state = state.copyWith( 70 + error: 'Failed to fetch suggestions', 71 + isLoading: false, 72 + ); 73 + } 74 + } 75 + 76 + void clear() { 77 + _debounce?.cancel(); 78 + state = ActorTypeaheadState.initial(); 79 + } 80 + }
+16
lib/src/features/search/providers/actor_typeahead_state.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 3 + 4 + part 'actor_typeahead_state.freezed.dart'; 5 + 6 + @freezed 7 + abstract class ActorTypeaheadState with _$ActorTypeaheadState { 8 + const factory ActorTypeaheadState({ 9 + @Default(false) bool isLoading, 10 + @Default([]) List<ProfileViewBasic> results, 11 + @Default('') String query, 12 + String? error, 13 + }) = _ActorTypeaheadState; 14 + 15 + factory ActorTypeaheadState.initial() => const ActorTypeaheadState(); 16 + }
+87 -12
lib/src/features/search/providers/post_search_provider.dart
··· 19 19 @riverpod 20 20 class PostSearch extends _$PostSearch { 21 21 Timer? _debounce; 22 + int _activeSearchToken = 0; 22 23 final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 23 24 'PostSearchProvider', 24 25 ); ··· 36 37 37 38 /// Update the search query and trigger search with debounce 38 39 void updateQuery(String query) { 40 + final trimmedQuery = query.trim(); 41 + 39 42 // Update query and reset pagination state 40 43 state = state.copyWith( 41 - query: query, 44 + query: trimmedQuery, 42 45 searchResults: [], 43 46 sprkNextCursor: null, 44 47 bskyNextCursor: null, 48 + isLoadingMore: false, 45 49 error: null, 46 50 ); 47 51 48 - if (query.isEmpty) { 52 + if (trimmedQuery.isEmpty) { 53 + _activeSearchToken++; 49 54 state = state.copyWith(isLoading: false); 50 55 return; 51 56 } ··· 55 60 56 61 // Debounce the search 57 62 if (_debounce?.isActive ?? false) _debounce!.cancel(); 63 + final requestToken = ++_activeSearchToken; 58 64 _debounce = Timer(const Duration(milliseconds: 500), () { 59 - _searchPosts(query); 65 + _searchPosts(trimmedQuery, requestToken: requestToken); 60 66 }); 61 67 } 62 68 69 + /// Submit the search query and run search immediately. 70 + Future<void> submitQuery(String query) async { 71 + final trimmedQuery = query.trim(); 72 + 73 + _debounce?.cancel(); 74 + 75 + state = state.copyWith( 76 + query: trimmedQuery, 77 + searchResults: [], 78 + sprkNextCursor: null, 79 + bskyNextCursor: null, 80 + error: null, 81 + isLoadingMore: false, 82 + ); 83 + 84 + if (trimmedQuery.isEmpty) { 85 + _activeSearchToken++; 86 + state = state.copyWith(isLoading: false); 87 + return; 88 + } 89 + 90 + final requestToken = ++_activeSearchToken; 91 + await _searchPosts(trimmedQuery, requestToken: requestToken); 92 + } 93 + 63 94 /// Search for posts with the given query 64 - Future<void> _searchPosts(String query) async { 95 + Future<void> _searchPosts(String query, {required int requestToken}) async { 65 96 if (query.isEmpty) return; 97 + if (requestToken != _activeSearchToken || state.query != query) { 98 + return; 99 + } 66 100 67 101 state = state.copyWith(isLoading: true, error: null); 68 102 ··· 80 114 ); 81 115 82 116 final results = await Future.wait([sprkSearch, bskySearch]); 117 + 118 + if (!ref.mounted || 119 + requestToken != _activeSearchToken || 120 + state.query != query) { 121 + return; 122 + } 83 123 84 124 final sprkResponse = 85 125 results[0] as ({String? cursor, List<PostView> posts}); ··· 130 170 // If we have very few results, try to load more immediately 131 171 if (state.searchResults.length < 10 && 132 172 (state.sprkNextCursor != null || state.bskyNextCursor != null)) { 133 - await loadMorePosts(); 173 + await _loadMorePostsForToken(requestToken); 134 174 } 135 175 } catch (e) { 176 + if (!ref.mounted || 177 + requestToken != _activeSearchToken || 178 + state.query != query) { 179 + return; 180 + } 181 + 136 182 _logger.e('Error searching posts: $e'); 137 183 state = state.copyWith(error: e.toString(), isLoading: false); 138 184 } ··· 140 186 141 187 /// Load more posts using the next cursor if available 142 188 Future<void> loadMorePosts() async { 189 + await _loadMorePostsForToken(_activeSearchToken); 190 + } 191 + 192 + Future<void> _loadMorePostsForToken(int requestToken) async { 193 + if (requestToken != _activeSearchToken) { 194 + return; 195 + } 196 + 143 197 if (state.isLoadingMore) return; 144 198 145 199 state = state.copyWith(isLoadingMore: true); 146 200 147 201 try { 148 - await _loadMorePostsRecursive(); 202 + await _loadMorePostsRecursive(requestToken); 149 203 } catch (e) { 204 + if (!ref.mounted || requestToken != _activeSearchToken) { 205 + return; 206 + } 207 + 150 208 _logger.e('Error loading more posts: $e'); 151 209 state = state.copyWith(error: e.toString(), isLoadingMore: false); 152 210 } finally { 153 - if (state.isLoadingMore) { 211 + if (ref.mounted && 212 + requestToken == _activeSearchToken && 213 + state.isLoadingMore) { 154 214 state = state.copyWith(isLoadingMore: false); 155 215 } 156 216 } 157 217 } 158 218 159 - Future<void> _loadMorePostsRecursive() async { 219 + Future<void> _loadMorePostsRecursive(int requestToken) async { 220 + final query = state.query; 221 + 160 222 final sprkCursor = state.sprkNextCursor; 161 223 if (sprkCursor != null && sprkCursor.isNotEmpty) { 162 224 final response = await _feedRepository.searchPosts( 163 - state.query, 225 + query, 164 226 cursor: sprkCursor, 165 227 ); 228 + 229 + if (!ref.mounted || 230 + requestToken != _activeSearchToken || 231 + state.query != query) { 232 + return; 233 + } 234 + 166 235 final filteredPosts = _filterHiddenPosts(response.posts); 167 236 state = state.copyWith( 168 237 searchResults: [...state.searchResults, ...filteredPosts], ··· 172 241 // If sprk is exhausted now and we have a bsky cursor, fetch from bsky. 173 242 if ((response.cursor == null || response.cursor!.isEmpty) && 174 243 (state.bskyNextCursor != null && state.bskyNextCursor!.isNotEmpty)) { 175 - await _loadMorePostsRecursive(); 244 + await _loadMorePostsRecursive(requestToken); 176 245 } 177 246 return; 178 247 } ··· 185 254 } 186 255 final bskyApi = bsky.Bluesky.fromOAuthSession(atproto.oAuthSession!); 187 256 final response = await bskyApi.feed.searchPosts( 188 - q: state.query, 257 + q: query, 189 258 sort: const FeedSearchPostsSort.unknown(data: 'latest'), 190 259 cursor: bskyCursor, 191 260 ); 261 + 262 + if (!ref.mounted || 263 + requestToken != _activeSearchToken || 264 + state.query != query) { 265 + return; 266 + } 192 267 193 268 final bskyPosts = response.data.posts 194 269 .asMap() ··· 231 306 if (state.searchResults.length < 10 && 232 307 (state.bskyNextCursor != null && state.bskyNextCursor!.isNotEmpty) && 233 308 state.searchResults.length > initialCount) { 234 - await _loadMorePostsRecursive(); 309 + await _loadMorePostsRecursive(requestToken); 235 310 } 236 311 } 237 312 }
+78 -5
lib/src/features/search/providers/search_provider.dart
··· 17 17 @riverpod 18 18 class Search extends _$Search { 19 19 Timer? _debounce; 20 + int _activeSearchToken = 0; 20 21 final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 21 22 'SearchProvider', 22 23 ); ··· 35 36 36 37 /// Update the search query and trigger search with debounce 37 38 void updateQuery(String query) { 39 + final trimmedQuery = query.trim(); 40 + 38 41 // Update query and reset pagination state 39 42 state = state.copyWith( 40 - query: query, 43 + query: trimmedQuery, 41 44 searchResults: [], 42 45 nextCursor: null, 46 + isLoadingMore: false, 43 47 error: null, 44 48 ); 45 49 46 - if (query.isEmpty) { 50 + if (trimmedQuery.isEmpty) { 51 + _activeSearchToken++; 47 52 state = state.copyWith( 48 53 searchResults: [], 49 54 error: null, ··· 56 61 57 62 // Debounce the search 58 63 if (_debounce?.isActive ?? false) _debounce!.cancel(); 64 + final requestToken = ++_activeSearchToken; 59 65 _debounce = Timer(const Duration(milliseconds: 500), () { 60 - _searchUsers(query); 66 + _searchUsers(trimmedQuery, requestToken: requestToken); 61 67 }); 68 + } 69 + 70 + /// Submit the search query and run search immediately. 71 + Future<void> submitQuery(String query) async { 72 + final trimmedQuery = query.trim(); 73 + 74 + _debounce?.cancel(); 75 + 76 + state = state.copyWith( 77 + query: trimmedQuery, 78 + searchResults: [], 79 + nextCursor: null, 80 + isLoadingMore: false, 81 + error: null, 82 + ); 83 + 84 + if (trimmedQuery.isEmpty) { 85 + _activeSearchToken++; 86 + state = state.copyWith( 87 + searchResults: [], 88 + error: null, 89 + isLoading: false, 90 + isLoadingMore: false, 91 + nextCursor: null, 92 + ); 93 + return; 94 + } 95 + 96 + final requestToken = ++_activeSearchToken; 97 + await _searchUsers(trimmedQuery, requestToken: requestToken); 62 98 } 63 99 64 100 /// Search for users with the given query 65 - Future<void> _searchUsers(String query) async { 101 + Future<void> _searchUsers(String query, {required int requestToken}) async { 66 102 if (query.isEmpty) return; 103 + if (requestToken != _activeSearchToken || state.query != query) { 104 + return; 105 + } 67 106 68 107 state = state.copyWith(isLoading: true, error: null); 69 108 70 109 try { 71 110 final actorRepo = _actorRepository; 72 111 final response = await actorRepo.searchActors(query); 112 + 113 + if (!ref.mounted || 114 + requestToken != _activeSearchToken || 115 + state.query != query) { 116 + return; 117 + } 73 118 74 119 state = state.copyWith( 75 120 searchResults: response.actors, ··· 78 123 isLoadingMore: false, 79 124 ); 80 125 } catch (e) { 126 + if (!ref.mounted || 127 + requestToken != _activeSearchToken || 128 + state.query != query) { 129 + return; 130 + } 131 + 81 132 _logger.e('Failed to search users', error: e); 82 133 state = state.copyWith(error: 'Failed to search users', isLoading: false); 83 134 } ··· 85 136 86 137 /// Load more users using the next cursor if available 87 138 Future<void> loadMoreUsers() async { 139 + final query = state.query; 140 + final requestToken = _activeSearchToken; 88 141 final nextCursor = state.nextCursor; 89 142 if (nextCursor == null || nextCursor.isEmpty || state.isLoadingMore) { 90 143 return; ··· 94 147 95 148 try { 96 149 final response = await _actorRepository.searchActors( 97 - state.query, 150 + query, 98 151 cursor: nextCursor, 99 152 ); 100 153 154 + if (!ref.mounted || 155 + requestToken != _activeSearchToken || 156 + state.query != query) { 157 + return; 158 + } 159 + 101 160 state = state.copyWith( 102 161 searchResults: [...state.searchResults, ...response.actors], 103 162 nextCursor: response.cursor, 104 163 isLoadingMore: false, 105 164 ); 106 165 } catch (e) { 166 + if (!ref.mounted || 167 + requestToken != _activeSearchToken || 168 + state.query != query) { 169 + return; 170 + } 171 + 107 172 _logger.e('Failed to load more users', error: e); 108 173 state = state.copyWith(isLoadingMore: false); 109 174 } ··· 119 184 120 185 final graphRepo = _graphRepository; 121 186 final response = await graphRepo.followUser(userDid); 187 + 188 + if (!ref.mounted) { 189 + return; 190 + } 122 191 123 192 // Update the user in the search results with the follow URI 124 193 final updatedResults = [...state.searchResults]; ··· 151 220 152 221 final graphRepo = _graphRepository; 153 222 await graphRepo.unfollowUser(followUri); 223 + 224 + if (!ref.mounted) { 225 + return; 226 + } 154 227 155 228 // Update the user in the search results to remove the follow URI 156 229 final updatedResults = [...state.searchResults];
+165 -25
lib/src/features/search/ui/pages/search_page.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:auto_route/auto_route.dart'; 2 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 5 import 'package:flutter/material.dart'; 4 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 7 import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; 6 8 import 'package:spark/src/core/design_system/templates/explore_page_template.dart'; 9 + import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 10 + import 'package:spark/src/core/routing/app_router.dart'; 11 + import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; 12 + import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; 7 13 import 'package:spark/src/features/search/providers/post_search_provider.dart'; 8 14 import 'package:spark/src/features/search/providers/search_provider.dart'; 9 15 import 'package:spark/src/features/search/providers/suggested_feeds_provider.dart'; ··· 24 30 25 31 class _SearchPageState extends ConsumerState<SearchPage> { 26 32 final TextEditingController _searchController = TextEditingController(); 33 + bool _showSubmittedResults = false; 27 34 28 35 @override 29 36 void initState() { ··· 41 48 42 49 void _onSearchChanged() { 43 50 final trimmedQuery = _searchController.text.trim(); 44 - ref.read(searchProvider.notifier).updateQuery(trimmedQuery); 45 - ref.read(postSearchProvider.notifier).updateQuery(trimmedQuery); 51 + 52 + if (_showSubmittedResults) { 53 + setState(() { 54 + _showSubmittedResults = false; 55 + }); 56 + } 57 + 58 + if (trimmedQuery.isEmpty) { 59 + ref.read(actorTypeaheadProvider.notifier).clear(); 60 + ref.read(searchProvider.notifier).updateQuery(''); 61 + ref.read(postSearchProvider.notifier).updateQuery(''); 62 + return; 63 + } 64 + 65 + ref.read(actorTypeaheadProvider.notifier).updateQuery(trimmedQuery); 66 + } 67 + 68 + Future<void> _submitFullSearch([String? query]) async { 69 + final trimmedQuery = (query ?? _searchController.text).trim(); 70 + 71 + if (trimmedQuery.isEmpty) { 72 + setState(() { 73 + _showSubmittedResults = false; 74 + }); 75 + ref.read(actorTypeaheadProvider.notifier).clear(); 76 + ref.read(searchProvider.notifier).updateQuery(''); 77 + ref.read(postSearchProvider.notifier).updateQuery(''); 78 + return; 79 + } 80 + 81 + ref.read(actorTypeaheadProvider.notifier).clear(); 82 + setState(() { 83 + _showSubmittedResults = true; 84 + }); 85 + 86 + await Future.wait([ 87 + ref.read(searchProvider.notifier).submitQuery(trimmedQuery), 88 + ref.read(postSearchProvider.notifier).submitQuery(trimmedQuery), 89 + ]); 90 + } 91 + 92 + void _onSuggestionSelected(ProfileViewBasic actor) { 93 + context.router.push( 94 + ProfileRoute(did: actor.did, initialProfile: actor), 95 + ); 96 + } 97 + 98 + void _onSubmitted(String _) { 99 + unawaited(_submitFullSearch()); 46 100 } 47 101 48 102 @override 49 103 Widget build(BuildContext context) { 50 - final searchState = ref.watch(searchProvider); 104 + final userSearchState = ref.watch(searchProvider); 105 + final postSearchState = ref.watch(postSearchProvider); 106 + final typeaheadState = ref.watch(actorTypeaheadProvider); 107 + final hasQuery = _searchController.text.trim().isNotEmpty; 108 + final hasSubmittedQuery = 109 + userSearchState.query.isNotEmpty || postSearchState.query.isNotEmpty; 110 + final showSubmittedResults = 111 + _showSubmittedResults && hasQuery && hasSubmittedQuery; 51 112 52 113 return DefaultTabController( 53 114 length: 2, ··· 55 116 searchWidget: InputField.search( 56 117 controller: _searchController, 57 118 hintText: 'Search users, posts...', 119 + onSubmitted: _onSubmitted, 120 + textInputAction: TextInputAction.search, 58 121 leadingWidgets: const [ 59 122 Icon( 60 123 FluentIcons.search_24_regular, ··· 64 127 actionWidgets: _searchController.text.isNotEmpty 65 128 ? [ 66 129 GestureDetector( 67 - onTap: () { 68 - _searchController.clear(); 69 - ref.read(searchProvider.notifier).updateQuery(''); 70 - ref.read(postSearchProvider.notifier).updateQuery(''); 71 - }, 130 + onTap: _searchController.clear, 72 131 child: const Icon( 73 132 FluentIcons.dismiss_24_regular, 74 133 size: 20, ··· 77 136 ] 78 137 : null, 79 138 ), 80 - showTabs: searchState.query.isNotEmpty, 139 + showTabs: showSubmittedResults, 81 140 tabsWidget: const TabBar( 82 141 tabs: [ 83 142 Tab(text: 'Posts'), ··· 90 149 UserResults(), 91 150 ], 92 151 ), 93 - emptyStateWidget: RefreshIndicator( 94 - onRefresh: () async { 95 - ref 96 - ..invalidate(storiesByAuthorProvider()) 97 - ..invalidate(suggestedFeedsProvider); 98 - }, 99 - child: const CustomScrollView( 100 - slivers: [ 101 - SliverToBoxAdapter(child: StoriesList()), 102 - SliverToBoxAdapter(child: SuggestedFeedsList()), 103 - SliverFillRemaining( 104 - hasScrollBody: false, 105 - child: Center( 106 - child: Text('Discover new content'), 152 + emptyStateWidget: hasQuery 153 + ? _ActorTypeaheadSuggestions( 154 + state: typeaheadState, 155 + onSuggestionSelected: _onSuggestionSelected, 156 + ) 157 + : RefreshIndicator( 158 + onRefresh: () async { 159 + ref 160 + ..invalidate(storiesByAuthorProvider()) 161 + ..invalidate(suggestedFeedsProvider); 162 + }, 163 + child: const CustomScrollView( 164 + slivers: [ 165 + SliverToBoxAdapter(child: StoriesList()), 166 + SliverToBoxAdapter(child: SuggestedFeedsList()), 167 + SliverFillRemaining( 168 + hasScrollBody: false, 169 + child: Center( 170 + child: Text('Discover new content'), 171 + ), 172 + ), 173 + ], 107 174 ), 108 175 ), 109 - ], 176 + ), 177 + ); 178 + } 179 + } 180 + 181 + class _ActorTypeaheadSuggestions extends StatelessWidget { 182 + const _ActorTypeaheadSuggestions({ 183 + required this.state, 184 + required this.onSuggestionSelected, 185 + }); 186 + 187 + final ActorTypeaheadState state; 188 + final ValueChanged<ProfileViewBasic> onSuggestionSelected; 189 + 190 + @override 191 + Widget build(BuildContext context) { 192 + final theme = Theme.of(context); 193 + 194 + if (state.isLoading && state.results.isEmpty) { 195 + return const Center( 196 + child: Padding( 197 + padding: EdgeInsets.all(24), 198 + child: CircularProgressIndicator(), 199 + ), 200 + ); 201 + } 202 + 203 + if (state.error != null && state.results.isEmpty) { 204 + return Center( 205 + child: Padding( 206 + padding: const EdgeInsets.all(24), 207 + child: Text( 208 + state.error!, 209 + style: theme.textTheme.bodyMedium?.copyWith( 210 + color: theme.colorScheme.error, 211 + ), 212 + textAlign: TextAlign.center, 110 213 ), 111 214 ), 112 - ), 215 + ); 216 + } 217 + 218 + if (state.results.isEmpty) { 219 + return Center( 220 + child: Padding( 221 + padding: const EdgeInsets.all(24), 222 + child: Text( 223 + 'No user suggestions', 224 + style: theme.textTheme.bodyMedium?.copyWith( 225 + color: theme.colorScheme.onSurfaceVariant, 226 + ), 227 + ), 228 + ), 229 + ); 230 + } 231 + 232 + return ListView.separated( 233 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 234 + itemCount: state.results.length, 235 + separatorBuilder: (context, index) => const Divider(height: 1), 236 + itemBuilder: (context, index) { 237 + final actor = state.results[index]; 238 + 239 + return ListTile( 240 + onTap: () => onSuggestionSelected(actor), 241 + contentPadding: const EdgeInsets.symmetric(vertical: 4), 242 + leading: CircleAvatar( 243 + radius: 18, 244 + backgroundImage: actor.avatar != null 245 + ? NetworkImage(actor.avatar.toString()) 246 + : null, 247 + child: actor.avatar == null ? const Icon(Icons.person) : null, 248 + ), 249 + title: Text(actor.displayName ?? actor.handle), 250 + subtitle: Text('@${actor.handle}'), 251 + ); 252 + }, 113 253 ); 114 254 } 115 255 }