From a0ed894016226eaa99a41c960696b971a3c92f4c Mon Sep 17 00:00:00 2001 From: Mguy13 Date: Fri, 23 Dec 2022 20:45:30 +0100 Subject: [PATCH] live local search todos improve local search --- .../core/data/constants/const_sorters.dart | 8 ++ .../core/data/extensions/map_extensions.dart | 3 + .../data/extensions/object_extensions.dart | 8 ++ .../data/extensions/string_extensions.dart | 14 ++++ .../home/services/images_service.dart | 39 ++++++++-- .../views/gallery/gallery_view_model.dart | 40 ++++++---- .../views/gallery/search_gallery_view.dart | 73 +++++++++++++------ pubspec.lock | 7 ++ pubspec.yaml | 1 + 9 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 lib/features/core/data/constants/const_sorters.dart create mode 100644 lib/features/core/data/extensions/object_extensions.dart create mode 100644 lib/features/core/data/extensions/string_extensions.dart diff --git a/lib/features/core/data/constants/const_sorters.dart b/lib/features/core/data/constants/const_sorters.dart new file mode 100644 index 0000000..a528a14 --- /dev/null +++ b/lib/features/core/data/constants/const_sorters.dart @@ -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)); +} diff --git a/lib/features/core/data/extensions/map_extensions.dart b/lib/features/core/data/extensions/map_extensions.dart index a185aa1..26f310a 100644 --- a/lib/features/core/data/extensions/map_extensions.dart +++ b/lib/features/core/data/extensions/map_extensions.dart @@ -1,3 +1,6 @@ extension MapExtensions on Map { Map get deepCopy => {...this}; + + /// Returns the values of a [Map] at given [keys] indices. + Iterable valuesByKeys({required Iterable keys}) => keys.map((final key) => this[key]!); } diff --git a/lib/features/core/data/extensions/object_extensions.dart b/lib/features/core/data/extensions/object_extensions.dart new file mode 100644 index 0000000..d5ea0e8 --- /dev/null +++ b/lib/features/core/data/extensions/object_extensions.dart @@ -0,0 +1,8 @@ +extension ObjectExtensions on Object? { + E asType() => this as E; + E? asNullableType() => this as E?; +} + +extension AsCallback on T { + T Function() get asCallback => () => this; +} diff --git a/lib/features/core/data/extensions/string_extensions.dart b/lib/features/core/data/extensions/string_extensions.dart new file mode 100644 index 0000000..d353aca --- /dev/null +++ b/lib/features/core/data/extensions/string_extensions.dart @@ -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 characterSet = Set.from(targetChars.split('')); + for (final testChar in split('')) { + characterSet.remove(testChar); + if (characterSet.isEmpty) return true; + } + + return false; + } +} diff --git a/lib/features/home/services/images_service.dart b/lib/features/home/services/images_service.dart index 0bf756c..a00f294 100644 --- a/lib/features/home/services/images_service.dart +++ b/lib/features/home/services/images_service.dart @@ -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 _imageModels; - Iterable get imageModels => _imageModels.deepCopy; + late final Map _imageModels; + Iterable get imageModels => _imageModels.values.deepCopy; + final Mutex _searchMutex = Mutex(); /// Manual initialization triggering @@ -36,7 +41,10 @@ class ImagesService { Future _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 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> 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, diff --git a/lib/features/home/views/gallery/gallery_view_model.dart b/lib/features/home/views/gallery/gallery_view_model.dart index f6e274b..65cdc5d 100644 --- a/lib/features/home/views/gallery/gallery_view_model.dart +++ b/lib/features/home/views/gallery/gallery_view_model.dart @@ -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 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; diff --git a/lib/features/home/views/gallery/search_gallery_view.dart b/lib/features/home/views/gallery/search_gallery_view.dart index 19d9e34..7ec68b3 100644 --- a/lib/features/home/views/gallery/search_gallery_view.dart +++ b/lib/features/home/views/gallery/search_gallery_view.dart @@ -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( + 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, + ), + ), + ), + ], + ); + } + }, ); } diff --git a/pubspec.lock b/pubspec.lock index ca2f9cb..67d91e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 1a242bf..7495f6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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