live local search
todos improve local search
This commit is contained in:
		
							parent
							
								
									c34d4f23ae
								
							
						
					
					
						commit
						53d13927fd
					
				
					 9 changed files with 151 additions and 42 deletions
				
			
		
							
								
								
									
										8
									
								
								lib/features/core/data/constants/const_sorters.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								lib/features/core/data/constants/const_sorters.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| import 'package:string_similarity/string_similarity.dart'; | ||||
| 
 | ||||
| abstract class ConstSorters { | ||||
|   /// Uses Dice's Coefficient as a similarity metric, for a 2-way comparison, between a [targetWord] | ||||
|   /// and given words. | ||||
|   static int stringsSimilarityTarget(String a, String b, {required String targetWord}) => | ||||
|       a.similarityTo(targetWord).compareTo(b.similarityTo(targetWord)); | ||||
| } | ||||
|  | @ -1,3 +1,6 @@ | |||
| extension MapExtensions<A, B> on Map<A, B> { | ||||
|   Map<A, B> get deepCopy => {...this}; | ||||
| 
 | ||||
|   /// Returns the values of a [Map] at given [keys] indices. | ||||
|   Iterable<B> valuesByKeys({required Iterable<A> keys}) => keys.map((final key) => this[key]!); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										8
									
								
								lib/features/core/data/extensions/object_extensions.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								lib/features/core/data/extensions/object_extensions.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| extension ObjectExtensions on Object? { | ||||
|   E asType<E>() => this as E; | ||||
|   E? asNullableType<E>() => this as E?; | ||||
| } | ||||
| 
 | ||||
| extension AsCallback<T extends Object> on T { | ||||
|   T Function() get asCallback => () => this; | ||||
| } | ||||
							
								
								
									
										14
									
								
								lib/features/core/data/extensions/string_extensions.dart
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/features/core/data/extensions/string_extensions.dart
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| extension StringExtensions on String { | ||||
|   /// Returns true if given word contains atleast all the characters in [targetChars], and `false` otherwise | ||||
|   /// | ||||
|   /// Very efficient `O(n)` instead of naive `O(n*m)` | ||||
|   bool containsAllCharacters({required String targetChars}) { | ||||
|     final Set<String> characterSet = Set.from(targetChars.split('')); | ||||
|     for (final testChar in split('')) { | ||||
|       characterSet.remove(testChar); | ||||
|       if (characterSet.isEmpty) return true; | ||||
|     } | ||||
| 
 | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
|  | @ -1,12 +1,16 @@ | |||
| import 'dart:async'; | ||||
| 
 | ||||
| import 'package:mc_gallery/features/core/data/extensions/string_extensions.dart'; | ||||
| 
 | ||||
| import '/features/core/data/constants/const_sorters.dart'; | ||||
| import '/features/core/data/extensions/iterable_extensions.dart'; | ||||
| import '/features/core/data/extensions/map_extensions.dart'; | ||||
| import '/features/core/services/logging_service.dart'; | ||||
| import '/features/core/utils/mutex.dart'; | ||||
| import '/features/home/data/models/image_model.dart'; | ||||
| import '/locator.dart'; | ||||
| import '../abstracts/images_api.dart'; | ||||
| import '../data/enums/search_option.dart'; | ||||
| import '../data/models/image_model.dart'; | ||||
| 
 | ||||
| /// Handles fetching and storing of Images. | ||||
| /// | ||||
|  | @ -24,8 +28,9 @@ class ImagesService { | |||
|   final ImagesApi _imagesApi; | ||||
|   final LoggingService _loggingService; | ||||
| 
 | ||||
|   late final Iterable<ImageModel> _imageModels; | ||||
|   Iterable<ImageModel> get imageModels => _imageModels.deepCopy; | ||||
|   late final Map<String, ImageModel> _imageModels; | ||||
|   Iterable<ImageModel> get imageModels => _imageModels.values.deepCopy; | ||||
| 
 | ||||
|   final Mutex _searchMutex = Mutex(); | ||||
| 
 | ||||
|   /// Manual initialization triggering | ||||
|  | @ -36,7 +41,10 @@ class ImagesService { | |||
| 
 | ||||
|   Future<void> _init() async { | ||||
|     _loggingService.info('Fetching and creating image models...'); | ||||
|     _imageModels = await _imagesApi.fetchImageUri(token: ''); | ||||
|     _imageModels = { | ||||
|       for (final imageModel in await _imagesApi.fetchImageUri(token: '')) | ||||
|         imageModel.imageName: imageModel | ||||
|     }; | ||||
| 
 | ||||
|     _imageModels.isNotEmpty | ||||
|         ? _loggingService.good("Created ${_imageModels.length} images' models") | ||||
|  | @ -49,19 +57,38 @@ class ImagesService { | |||
|   int get lastAvailableImageIndex => _imageModels.length - 1; | ||||
|   int get numberOfImages => _imageModels.length; | ||||
| 
 | ||||
|   ImageModel imageModelAt({required int index}) => _imageModels.elementAt(index); | ||||
|   ImageModel imageModelAt({required int index}) => _imageModels.values.elementAt(index); | ||||
| 
 | ||||
|   Future<void> get lastQueryIsCompleted => _searchMutex.lastOperationCompletionAwaiter; | ||||
| 
 | ||||
|   /// Performs searching on images, both locally and by a Web API endpoint. | ||||
|   /// | ||||
|   /// For now, a simple mechanism is used for handling async calls between (posssible) API fetches -> | ||||
|   /// just 'pile-up'. A mechanism can be made to 'cancel' a fetch if a newer search request comes in, | ||||
|   /// but that may be more complicated, and not the point of the assignment I think. | ||||
|   /// There are lots of optimizations possible for new inputs, for example reducing search frontier | ||||
|   /// by using set-cover/subsetting optimizations on backspace, and so on, but again, not the point, | ||||
|   /// I think. | ||||
|   Future<List<ImageModel>> searchImages({ | ||||
|     required SearchOption searchOption, | ||||
|     required String imageNamePart, | ||||
|     bool treatAsInSequence = false, | ||||
|   }) async { | ||||
|     return await _searchMutex.lockAndRun(run: (final unlock) async { | ||||
|       try { | ||||
|         switch (searchOption) { | ||||
|           case SearchOption.local: | ||||
|             return []; | ||||
|             final rankedKeys = _imageModels.keys | ||||
|                 // Reduce number of results by atleast occurring | ||||
|                 .where((final imageName) => treatAsInSequence | ||||
|                     ? imageName.contains(imageNamePart) | ||||
|                     : imageName.containsAllCharacters(targetChars: imageNamePart)) | ||||
|                 .toList(growable: false) | ||||
|               // Sorting by the highest similarity first | ||||
|               ..sort((final a, final b) => | ||||
|                   ConstSorters.stringsSimilarityTarget(targetWord: imageNamePart, a, b)) | ||||
|               ..reversed; | ||||
|             return _imageModels.valuesByKeys(keys: rankedKeys).toList(growable: false); | ||||
|           case SearchOption.web: | ||||
|             return await _imagesApi.searchImages( | ||||
|               searchStr: imageNamePart, | ||||
|  |  | |||
|  | @ -7,12 +7,12 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; | |||
| import '/features/core/abstracts/base_view_model.dart'; | ||||
| import '/features/core/services/logging_service.dart'; | ||||
| import '/features/core/services/navigation_service.dart'; | ||||
| import '/features/home/data/models/image_model.dart'; | ||||
| import '/features/home/services/image_cache_manager_service.dart'; | ||||
| import '/features/home/services/images_service.dart'; | ||||
| import '/features/home/views/image_carousel/image_carousel_view.dart'; | ||||
| import '/locator.dart'; | ||||
| import '../../data/enums/search_option.dart'; | ||||
| import '../../data/models/image_model.dart'; | ||||
| import '../../services/image_cache_manager_service.dart'; | ||||
| import '../../services/images_service.dart'; | ||||
| import '../image_carousel/image_carousel_view.dart'; | ||||
| 
 | ||||
| class GalleryViewModel extends BaseViewModel { | ||||
|   GalleryViewModel({ | ||||
|  | @ -54,17 +54,20 @@ class GalleryViewModel extends BaseViewModel { | |||
|     // If empty-string (from backspacing) -> reset state. | ||||
|     if (searchTerm.isEmpty) { | ||||
|       _imageSearchResultsNotifier.value = []; | ||||
|       _loggingService.info('Clearing results on search string removal'); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Detached call to prevent UI blocking | ||||
|     unawaited(_imagesService | ||||
|         .searchImages( | ||||
|           imageNamePart: searchTerm, | ||||
|           searchOption: searchOptionListenable.value, | ||||
|         ) | ||||
|         .then( | ||||
|             (final fetchedImageModels) => _imageSearchResultsNotifier.value = fetchedImageModels)); | ||||
|     unawaited( | ||||
|       _imagesService | ||||
|           .searchImages( | ||||
|             imageNamePart: searchTerm, | ||||
|             searchOption: searchOptionListenable.value, | ||||
|           ) | ||||
|           .then( | ||||
|               (final fetchedImageModels) => _imageSearchResultsNotifier.value = fetchedImageModels), | ||||
|     ); | ||||
| 
 | ||||
|     // Force-update to trigger listening to `lastQueryResultDone()`. | ||||
|     _imageSearchResultsNotifier.notifyListeners(); | ||||
|  | @ -72,14 +75,25 @@ class GalleryViewModel extends BaseViewModel { | |||
| 
 | ||||
|   void searchPressed() { | ||||
|     // If transitioning from 'Searching', clear previous results immediately | ||||
|     if (_isSearchingNotifier.value) _imageSearchResultsNotifier.value = []; | ||||
|     if (_isSearchingNotifier.value) { | ||||
|       _imageSearchResultsNotifier.value = []; | ||||
|       _loggingService.info('Clearing of results on view mode change'); | ||||
|     } | ||||
| 
 | ||||
|     _isSearchingNotifier.value = !_isSearchingNotifier.value; | ||||
|   } | ||||
| 
 | ||||
|   Future<void> get lastQueryResultDone => _imagesService.lastQueryIsCompleted; | ||||
| 
 | ||||
|   void onSearchOptionChanged(SearchOption? option) => _searchOptionNotifier.value = option!; | ||||
|   void onSearchOptionChanged(SearchOption? option) { | ||||
|     _searchOptionNotifier.value = option!; | ||||
|     _loggingService.info('Switched over to $option search'); | ||||
| 
 | ||||
|     _imageSearchResultsNotifier.value = []; | ||||
|     _loggingService.info('Cleared resultsw from view'); | ||||
| 
 | ||||
|     //todo(mehul): Either redo search or force user to type in by clearing field | ||||
|   } | ||||
| 
 | ||||
|   void onPromptPressed() => _isDisplayingPressingPrompt.value = false; | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,29 +24,56 @@ class _SearchGalleryView extends StatelessWidget { | |||
|               displayedWidget = const CircularProgressIndicator(); | ||||
|               break; | ||||
|             case ConnectionState.done: | ||||
|               displayedWidget = Wrap( | ||||
|                 runSpacing: 24, | ||||
|                 spacing: 8, | ||||
|                 alignment: WrapAlignment.center, | ||||
|                 runAlignment: WrapAlignment.center, | ||||
|                 crossAxisAlignment: WrapCrossAlignment.center, | ||||
|                 children: [ | ||||
|                   for (final imageResult in resultsImageModels) | ||||
|                     Image.network( | ||||
|                       imageResult.uri.toString(), | ||||
|                       loadingBuilder: (context, final child, final loadingProgress) => | ||||
|                           loadingProgress == null | ||||
|                               ? child | ||||
|                               : Center( | ||||
|                                   child: CircularProgressIndicator( | ||||
|                                     value: loadingProgress.expectedTotalBytes != null | ||||
|                                         ? loadingProgress.cumulativeBytesLoaded / | ||||
|                                             loadingProgress.expectedTotalBytes! | ||||
|                                         : null, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                     ), | ||||
|                 ], | ||||
|               displayedWidget = ValueListenableBuilder<SearchOption>( | ||||
|                 valueListenable: galleryViewModel.searchOptionListenable, | ||||
|                 builder: (context, final searchOption, child) { | ||||
|                   switch (searchOption) { | ||||
|                     case SearchOption.local: | ||||
|                       return Wrap( | ||||
|                         runSpacing: 24, | ||||
|                         spacing: 8, | ||||
|                         alignment: WrapAlignment.center, | ||||
|                         runAlignment: WrapAlignment.center, | ||||
|                         crossAxisAlignment: WrapCrossAlignment.center, | ||||
|                         children: [ | ||||
|                           for (final resultsImageModel in resultsImageModels) | ||||
|                             CachedNetworkImage( | ||||
|                               imageUrl: resultsImageModel.uri.toString(), | ||||
|                               cacheKey: resultsImageModel.imageIndex.toString(), | ||||
|                               progressIndicatorBuilder: (_, __, final progress) => | ||||
|                                   CircularProgressIndicator( | ||||
|                                 value: galleryViewModel.downloadProgressValue(progress: progress), | ||||
|                               ), | ||||
|                             ), | ||||
|                         ], | ||||
|                       ); | ||||
|                     case SearchOption.web: | ||||
|                       return Wrap( | ||||
|                         runSpacing: 24, | ||||
|                         spacing: 8, | ||||
|                         alignment: WrapAlignment.center, | ||||
|                         runAlignment: WrapAlignment.center, | ||||
|                         crossAxisAlignment: WrapCrossAlignment.center, | ||||
|                         children: [ | ||||
|                           for (final imageResult in resultsImageModels) | ||||
|                             Image.network( | ||||
|                               imageResult.uri.toString(), | ||||
|                               loadingBuilder: (context, final child, final loadingProgress) => | ||||
|                                   loadingProgress == null | ||||
|                                       ? child | ||||
|                                       : Center( | ||||
|                                           child: CircularProgressIndicator( | ||||
|                                             value: loadingProgress.expectedTotalBytes != null | ||||
|                                                 ? loadingProgress.cumulativeBytesLoaded / | ||||
|                                                     loadingProgress.expectedTotalBytes! | ||||
|                                                 : null, | ||||
|                                           ), | ||||
|                                         ), | ||||
|                             ), | ||||
|                         ], | ||||
|                       ); | ||||
|                   } | ||||
|                 }, | ||||
|               ); | ||||
|           } | ||||
| 
 | ||||
|  |  | |||
|  | @ -546,6 +546,13 @@ packages: | |||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.1.1" | ||||
|   string_similarity: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: string_similarity | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|   synchronized: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  |  | |||
|  | @ -29,6 +29,7 @@ dependencies: | |||
|   intl_utils: ^2.8.1 | ||||
|   connectivity_plus: ^3.0.2 | ||||
|   internet_connection_checker: ^1.0.0+1 | ||||
|   string_similarity: ^2.0.0 | ||||
| 
 | ||||
|   # Util frontend | ||||
|   flutter_markdown: ^0.6.13 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue