import 'dart:async'; import 'dart:collection'; import 'package:collection/collection.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/data/extensions/string_extensions.dart'; import '/features/core/services/local_storage_service.dart'; import '/features/core/services/logging_service.dart'; import '/features/core/utils/mutex.dart'; import '/features/home/data/dtos/image_model_dto.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. /// /// Since this is very simple use-case, this is the only interface. For complex (actual CRUD-based) I/O, /// an additional Repository layer interface can be used between [ImagesService] and [ImagesApi]. class ImagesService { ImagesService({ required ImagesApi imagesApi, required LocalStorageService localStorageService, required LoggingService loggingService, }) : _imagesApi = imagesApi, _localStorageService = localStorageService, _loggingService = loggingService { _init(); } final ImagesApi _imagesApi; final LocalStorageService _localStorageService; final LoggingService _loggingService; late final LinkedHashMap _imageModels; Iterable get imageModels => _imageModels.values.deepCopy; final Mutex _searchMutex = Mutex(); /// Manual initialization triggering /// /// Since this service not critical to app-start-up [Locator]'s `signalsReady=true` is not used. final Completer _initAwaiter = Completer(); Future get initAwaiter => _initAwaiter.future; Future _init() async { _loggingService.info('Fetching and creating image models...'); final fetchedImageModelDtos = await _imagesApi.fetchImageUri(); final favouritesStatuses = _localStorageService.storedFavouritesStates; // Prefill from stored values if (favouritesStatuses.isNotEmpty) { _loggingService.fine('Found favourites statuses on device -> Prefilling'); assert( fetchedImageModelDtos.length == favouritesStatuses.length, 'Downloaded images must be the same number as the statuses stored on device', ); _imageModels = LinkedHashMap.of({ for (final zippedDtosAndFavourites in IterableZip([fetchedImageModelDtos, favouritesStatuses])) (zippedDtosAndFavourites[0] as ImageModelDTO).imageName: ImageModel.fromDto( imageModelDto: zippedDtosAndFavourites[0] as ImageModelDTO, isFavourite: zippedDtosAndFavourites[1] as bool, ) }); // Set to false and create the stored values } else { _loggingService.good('NO favourites statuses found -> creating new'); _imageModels = LinkedHashMap.of({ for (final fetchedImageModelDto in fetchedImageModelDtos) fetchedImageModelDto.imageName: ImageModel.fromDto( imageModelDto: fetchedImageModelDto, isFavourite: false, ) }); _localStorageService.initNewFavourites( newValues: _imageModels.values.map((final imageModel) => imageModel.isFavourite), ); } _imageModels.isNotEmpty ? _loggingService.good("Created ${_imageModels.length} images' models") : _loggingService.warning('No images found'); _initAwaiter.complete(); } int get firstAvailableImageIndex => 0; int get lastAvailableImageIndex => _imageModels.length - 1; int get numberOfImages => _imageModels.length; 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: 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, )) .map( (final imageModelDto) => ImageModel.fromDto( imageModelDto: imageModelDto, isFavourite: false, ), ) .toList(growable: false); } } finally { unlock(); } }); } void updateImageFavouriteStatus({ required ImageModel imageModel, required bool newFavouriteStatus, }) { _imageModels.updateValueAt( valueIndex: imageModel.imageIndex, newValue: imageModel.copyWith(isFavourite: newFavouriteStatus), ); //todo(mehul): Consider adding an update listener to _imageModels, sync with _localStorageService _localStorageService.updateFavourite( index: imageModel.imageIndex, newValue: newFavouriteStatus); } static ImagesService get locate => Locator.locate(); }