diff --git a/README.md b/README.md index 9567448..5b90e68 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ # mc_gallery -A new Flutter project. +## Dart docs explanation -## Getting Started +## Emulation -This project is a starting point for a Flutter application. +## Maintaining scope +It's an 'assignment' -A few resources to get you started if this is your first Flutter project: +## Model vs. DTO -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## Extra quirks +Just because I had those assets lying around \ No newline at end of file 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/home/abstracts/images_api.dart b/lib/features/home/abstracts/images_api.dart index a0b2475..de9314f 100644 --- a/lib/features/home/abstracts/images_api.dart +++ b/lib/features/home/abstracts/images_api.dart @@ -1,15 +1,13 @@ import 'dart:async'; -import '../data/models/image_model.dart'; - /// Interface for implementing image-fetching strategies, specific to a resource location on the internet. /// /// Since I used a site that was more obscure than the ones in the examples, this (otherwise pointless /// and convoluting) interface is for adding a bit of flexibility to change strategy to some other site. abstract class ImagesApi { - FutureOr> fetchImageUri({required String token}); + FutureOr>> fetchImageUri({required String token}); - FutureOr> searchImages({ + FutureOr>> searchImages({ required String searchStr, required String token, }); diff --git a/lib/features/home/api/unsplash_images_api.dart b/lib/features/home/api/unsplash_images_api.dart index 8efe727..0ed3974 100644 --- a/lib/features/home/api/unsplash_images_api.dart +++ b/lib/features/home/api/unsplash_images_api.dart @@ -14,14 +14,15 @@ class UnsplashImagesApi implements ImagesApi { final random = Random(); @override - FutureOr> fetchImageUri({required String token}) async { + FutureOr>> fetchImageUri({required String token}) async { // Dummy fetching delay emulation await Future.delayed(const Duration( milliseconds: ConstValues.defaultEmulatedLatencyMillis * ConstValues.numberOfImages)); try { // Create fixed number of images - return Iterable.generate(ConstValues.numberOfImages).map((final imageIndex) { + final dummyImageModels = + Iterable.generate(ConstValues.numberOfImages).map((final imageIndex) { // Drawing from a normal distribution final imageSide = random.nextIntInRange(min: ConstValues.minImageSize, max: ConstValues.maxImageSize); @@ -36,6 +37,9 @@ class UnsplashImagesApi implements ImagesApi { imageName: Strings.current.imageNameFetch(imageIndex + 1, imageSide), ); }); + + // Emulating serialization + return dummyImageModels.map((final dummyModel) => dummyModel.toJson()); } on Exception catch (ex, stackTrace) { _loggingService.handleException(ex, stackTrace); return const Iterable.empty(); @@ -43,7 +47,7 @@ class UnsplashImagesApi implements ImagesApi { } @override - FutureOr> searchImages({ + FutureOr>> searchImages({ required String searchStr, required String token, }) async { @@ -55,7 +59,7 @@ class UnsplashImagesApi implements ImagesApi { try { // Create (randomly-bounded) dummy number of images - return Iterable.generate(numberOfResults).map((final imageIndex) { + final dummyImageModels = Iterable.generate(numberOfResults).map((final imageIndex) { // Drawing from a normal distribution final imageSide = random.nextIntInRange(min: ConstValues.minImageSize, max: ConstValues.maxImageSize); @@ -68,7 +72,10 @@ class UnsplashImagesApi implements ImagesApi { // Custom dummy name for the image imageName: Strings.current.imageNameSearch(searchStr, imageIndex + 1), ); - }).toList(growable: false); + }); + + // Emulating serialization + return dummyImageModels.map((final dummyModel) => dummyModel.toJson()); } on Exception catch (ex, stackTrace) { _loggingService.handleException(ex, stackTrace); return List.empty(); diff --git a/lib/features/home/data/models/image_model.dart b/lib/features/home/data/models/image_model.dart index 959fb3e..4765552 100644 --- a/lib/features/home/data/models/image_model.dart +++ b/lib/features/home/data/models/image_model.dart @@ -1,3 +1,8 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'image_model.g.dart'; + +@JsonSerializable() class ImageModel { const ImageModel({ required this.uri, @@ -15,4 +20,9 @@ class ImageModel { /// Given name of the image. final String imageName; + + factory ImageModel.fromJson(Map json) => _$ImageModelFromJson(json); + + /// Connect the generated [_$PersonToJson] function to the `toJson` method. + Map toJson() => _$ImageModelToJson(this); } diff --git a/lib/features/home/data/models/image_model.g.dart b/lib/features/home/data/models/image_model.g.dart new file mode 100644 index 0000000..fd16a20 --- /dev/null +++ b/lib/features/home/data/models/image_model.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'image_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ImageModel _$ImageModelFromJson(Map json) => ImageModel( + uri: Uri.parse(json['uri'] as String), + imageIndex: json['imageIndex'] as int, + imageName: json['imageName'] as String, + ); + +Map _$ImageModelToJson(ImageModel instance) => + { + 'uri': instance.uri.toString(), + 'imageIndex': instance.imageIndex, + 'imageName': instance.imageName, + }; diff --git a/lib/features/home/services/images_service.dart b/lib/features/home/services/images_service.dart index 0bf756c..9a1fda7 100644 --- a/lib/features/home/services/images_service.dart +++ b/lib/features/home/services/images_service.dart @@ -1,12 +1,14 @@ import 'dart:async'; +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 +26,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 +39,11 @@ 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: '')) + .map((final emulatedModelSerialized) => ImageModel.fromJson(emulatedModelSerialized))) + imageModel.imageName: imageModel + }; _imageModels.isNotEmpty ? _loggingService.good("Created ${_imageModels.length} images' models") @@ -49,24 +56,42 @@ 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; - Future> searchImages({ - required SearchOption searchOption, - required String imageNamePart, - }) async { + /// 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 + //todo(mehul): Implement atleast-matching-all-parts + .where( + (final imageName) => imageName.contains(treatAsInSequence ? imageNamePart : '')) + .toList(growable: false) + ..sort((final a, final b) => + ConstSorters.stringsSimilarityTarget(targetWord: imageNamePart, a, b)); + return _imageModels.valuesByKeys(keys: rankedKeys).toList(growable: false); case SearchOption.web: - return await _imagesApi.searchImages( + return (await _imagesApi.searchImages( searchStr: imageNamePart, token: '', - ); + )) + .map( + (final emulatedModelSerialized) => ImageModel.fromJson(emulatedModelSerialized)) + .toList(growable: false); } } finally { unlock(); diff --git a/lib/features/home/views/gallery/gallery_view_model.dart b/lib/features/home/views/gallery/gallery_view_model.dart index f6e274b..ac15c37 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,22 @@ 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, + // todo(mehul): When implemented, remove this + treatAsInSequence: true, + ) + .then( + (final fetchedImageModels) => _imageSearchResultsNotifier.value = fetchedImageModels), + ); // Force-update to trigger listening to `lastQueryResultDone()`. _imageSearchResultsNotifier.notifyListeners(); @@ -72,14 +77,20 @@ 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 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('Switching over to $option search'); + } void onPromptPressed() => _isDisplayingPressingPrompt.value = false; diff --git a/pubspec.lock b/pubspec.lock index ca2f9cb..5a3a54c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.3" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.7" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.4.2" cached_network_image: dependency: "direct main" description: @@ -92,6 +148,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" clock: dependency: transitive description: @@ -99,6 +162,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" collection: dependency: transitive description: @@ -183,6 +253,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" flutter: dependency: "direct main" description: flutter @@ -233,6 +310,13 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" get_it: dependency: "direct main" description: @@ -254,6 +338,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.0.0" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" http: dependency: transitive description: @@ -261,6 +352,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.13.5" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.1" http_parser: dependency: transitive description: @@ -289,6 +387,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.8.1" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" js: dependency: transitive description: @@ -296,6 +401,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.4" + json_annotation: + dependency: "direct dev" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.7.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "6.5.4" lints: dependency: transitive description: @@ -338,6 +457,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" nested: dependency: transitive description: @@ -471,6 +597,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.6.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" process: dependency: transitive description: @@ -492,6 +625,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.3" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" rxdart: dependency: transitive description: @@ -499,11 +639,39 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.27.7" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.6" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.3" source_span: dependency: transitive description: @@ -539,6 +707,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" string_scanner: dependency: transitive description: @@ -546,6 +721,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: @@ -588,6 +770,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.12" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" typed_data: dependency: transitive description: @@ -616,6 +805,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1a242bf..5bc5132 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 @@ -49,6 +50,11 @@ dev_dependencies: flutter_lints: ^2.0.1 + # Builders + build_runner: ^2.3.3 + json_annotation: ^4.7.0 + json_serializable: ^6.5.4 + flutter: uses-material-design: true