live WEB search

This commit is contained in:
Mehul Ahal 2022-12-23 11:20:46 +01:00 committed by Mguy13
parent 47945dbec7
commit 4ade7f1682
19 changed files with 503 additions and 59 deletions

View File

@ -6,4 +6,6 @@ abstract class ConstValues {
static const int numberOfImages = 20;
static const int minImageSize = 50;
static const int maxImageSize = 100;
static const int defaultEmulatedLatencyMillis = 75;
}

View File

@ -0,0 +1,3 @@
extension IterableExtensions<T> on Iterable<T> {
Iterable<T> get deepCopy => toList(growable: false);
}

View File

@ -0,0 +1,9 @@
import 'dart:math';
extension RandomExtensions on Random {
int nextIntInRange({
required int min,
required int max,
}) =>
min + nextInt(max + 1 - min);
}

View File

@ -0,0 +1,25 @@
import 'dart:async';
import 'dart:collection';
import 'dart:ui';
/// A simple Mutex implementation using a [Completer].
class Mutex {
final _completerQueue = Queue<Completer>();
/// Runs the given [run] function-block in a thread-safe/blocked zone. A convenient `unlock()`
/// is provided, which can be called anywhere to signal re-entry.
FutureOr<T> lockAndRun<T>({
required FutureOr<T> Function(VoidCallback unlock) run,
}) async {
final completer = Completer();
_completerQueue.add(completer);
if (_completerQueue.first != completer) {
await _completerQueue.removeFirst().future;
}
final value = await run(() => completer.complete());
return value;
}
Future<void> get lastOperationCompletionAwaiter =>
_completerQueue.isNotEmpty ? _completerQueue.last.future : Future.value();
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart' show Listenable, ValueListenable;
import 'package:flutter/widgets.dart';
/// This widget listens to multiple [ValueListenable]s and calls given builder function if any one of them changes.
class MultiValueListenableBuilder extends StatelessWidget {
const MultiValueListenableBuilder({
required this.valueListenables,
required this.builder,
this.child,
super.key,
}) : assert(valueListenables.length != 0);
/// List of [ValueListenable]s to be listened to.
final List<ValueListenable> valueListenables;
/// The builder function to be called when value of any of the [ValueListenable] changes.
/// The order of values list will be same as [valueListenables] list.
final Widget Function(BuildContext context, List<dynamic> values, Widget? child) builder;
/// An optional child widget which will be available as child parameter in [builder].
final Widget? child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge(valueListenables),
builder: (context, child) {
final providedValues = valueListenables.map((final listenable) => listenable.value);
return builder(context, List<dynamic>.unmodifiable(providedValues), child);
},
child: child,
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../abstracts/base_view_model.dart';
import '../../abstracts/base_view_model.dart';
class ViewModelBuilder<T extends BaseViewModel> extends StatefulWidget {
const ViewModelBuilder({

View File

@ -8,4 +8,9 @@ import '../data/models/image_model.dart';
/// and convoluting) interface is for adding a bit of flexibility to change strategy to some other site.
abstract class ImagesApi {
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token});
FutureOr<List<ImageModel>> searchImages({
required String searchStr,
required String token,
});
}

View File

@ -1,39 +1,80 @@
import 'dart:async';
import 'dart:math';
import 'package:mc_gallery/features/core/services/logging_service.dart';
import 'package:mc_gallery/locator.dart';
import '/features/core/data/constants/const_values.dart';
import '/features/core/data/extensions/random_extensions.dart';
import '/features/core/services/logging_service.dart';
import '/l10n/generated/l10n.dart';
import '/locator.dart';
import '../abstracts/images_api.dart';
import '../data/models/image_model.dart';
class UnsplashImagesApi with LoggingService implements ImagesApi {
class UnsplashImagesApi implements ImagesApi {
final LoggingService _loggingService = LoggingService.locate;
final random = Random();
@override
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token}) {
final random = Random();
FutureOr<Iterable<ImageModel>> 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<int>.generate(ConstValues.numberOfImages).map((final imageIndex) {
// Drawing from a normal distribution
final imageSide = ConstValues.minImageSize +
random.nextInt((ConstValues.maxImageSize + 1) - ConstValues.minImageSize);
final imageSide =
random.nextIntInRange(min: ConstValues.minImageSize, max: ConstValues.maxImageSize);
final imageUri = _imageUrlGenerator(imageSide: imageSide);
return ImageModel(
imageIndex: imageIndex,
uri: imageUri,
imageName: '${Strings.current.image} ${imageIndex + 1}: size=$imageSide',
// Custom dummy name for the image
imageName: Strings.current.imageNameFetch(imageIndex + 1, imageSide),
);
});
} on Exception catch (ex, stackTrace) {
handleException(ex, stackTrace);
_loggingService.handleException(ex, stackTrace);
return const Iterable.empty();
}
}
@override
FutureOr<List<ImageModel>> searchImages({
required String searchStr,
required String token,
}) async {
final numberOfResults = random.nextIntInRange(min: 0, max: ConstValues.numberOfImages);
// Dummy fetching delay emulation
await Future.delayed(
Duration(milliseconds: ConstValues.defaultEmulatedLatencyMillis * numberOfResults));
try {
// Create (randomly-bounded) dummy number of images
return Iterable<int>.generate(numberOfResults).map((final imageIndex) {
// Drawing from a normal distribution
final imageSide =
random.nextIntInRange(min: ConstValues.minImageSize, max: ConstValues.maxImageSize);
final imageUri = _imageUrlGenerator(imageSide: imageSide);
return ImageModel(
imageIndex: imageIndex,
uri: imageUri,
// Custom dummy name for the image
imageName: Strings.current.imageNameSearch(searchStr, imageIndex + 1),
);
}).toList(growable: false);
} on Exception catch (ex, stackTrace) {
_loggingService.handleException(ex, stackTrace);
return List.empty();
}
}
Uri _imageUrlGenerator({required int imageSide}) => Uri(
scheme: ConstValues.httpsScheme,
host: ConstValues.backendHost,

View File

@ -0,0 +1,15 @@
import '/l10n/generated/l10n.dart';
enum SearchOption {
local,
web;
String get name {
switch (this) {
case SearchOption.local:
return Strings.current.local;
case SearchOption.web:
return Strings.current.web;
}
}
}

View File

@ -1,8 +1,12 @@
import 'package:mc_gallery/features/core/services/logging_service.dart';
import 'package:mc_gallery/features/home/data/models/image_model.dart';
import 'package:mc_gallery/locator.dart';
import 'dart:async';
import '/features/core/data/extensions/iterable_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';
/// Handles fetching and storing of Images.
///
@ -21,7 +25,14 @@ class ImagesService {
final LoggingService _loggingService;
late final Iterable<ImageModel> _imageModels;
Iterable<ImageModel> get imageModels => _imageModels;
Iterable<ImageModel> get imageModels => _imageModels.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<void> _init() async {
_loggingService.info('Fetching and creating image models...');
@ -31,7 +42,7 @@ class ImagesService {
? _loggingService.good("Created ${_imageModels.length} images' models")
: _loggingService.warning('No images found');
Locator.instance().signalReady(this);
_initAwaiter.complete();
}
int get firstAvailableImageIndex => 0;
@ -40,5 +51,28 @@ class ImagesService {
ImageModel imageModelAt({required int index}) => _imageModels.elementAt(index);
Future<void> get lastQueryIsCompleted => _searchMutex.lastOperationCompletionAwaiter;
Future<List<ImageModel>> searchImages({
required SearchOption searchOption,
required String imageNamePart,
}) async {
return await _searchMutex.lockAndRun(run: (final unlock) async {
try {
switch (searchOption) {
case SearchOption.local:
return [];
case SearchOption.web:
return await _imagesApi.searchImages(
searchStr: imageNamePart,
token: '',
);
}
} finally {
unlock();
}
});
}
static ImagesService get locate => Locator.locate();
}

View File

@ -0,0 +1,44 @@
part of 'gallery_view.dart';
class _DownloadedGalleryView extends StatelessWidget {
const _DownloadedGalleryView({
required this.galleryViewModel,
super.key,
});
final GalleryViewModel galleryViewModel;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(color: ConstColours.galleryBackgroundColour),
child: Padding(
padding: const EdgeInsets.all(8),
// Using Wrap instead of GridView, to make use of different image sizes
child: Wrap(
runSpacing: 24,
spacing: 8,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final imageModel in galleryViewModel.imageModels)
GestureDetector(
onTap: () => galleryViewModel.pushImageCarouselView(
context,
imageModel: imageModel,
),
child: CachedNetworkImage(
imageUrl: imageModel.uri.toString(),
cacheKey: imageModel.imageIndex.toString(),
progressIndicatorBuilder: (_, __, final progress) => CircularProgressIndicator(
value: galleryViewModel.downloadProgressValue(progress: progress),
),
),
),
],
),
),
);
}
}

View File

@ -3,10 +3,17 @@ import 'package:flutter/material.dart';
import '/features/core/data/constants/const_colors.dart';
import '/features/core/data/constants/const_durations.dart';
import '/features/core/widgets/gap.dart';
import '/features/core/widgets/mcg_scaffold.dart';
import '/features/core/widgets/view_model_builder.dart';
import '/features/core/widgets/state/multi_value_listenable_builder.dart';
import '/features/core/widgets/state/view_model_builder.dart';
import '../../data/enums/search_option.dart';
import '../../data/models/image_model.dart';
import 'gallery_view_model.dart';
part 'downloaded_gallery_view.dart';
part 'search_gallery_view.dart';
class GalleryView extends StatelessWidget {
const GalleryView({super.key});
@ -18,7 +25,36 @@ class GalleryView extends StatelessWidget {
bodyBuilderWaiter: model.isInitialised,
forceInternetCheck: true,
appBar: AppBar(
title: Text(model.strings.gallery),
centerTitle: true,
primary: true,
title: ValueListenableBuilder<bool>(
valueListenable: model.isSearchingListenable,
builder: (context, final isSearching, _) => AnimatedSwitcher(
duration: ConstDurations.quarterDefaultAnimationDuration,
child: !isSearching
? Center(child: Text(model.strings.gallery))
: _SearchBox(galleryViewModel: model),
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 40),
child: MultiValueListenableBuilder(
valueListenables: [
model.isDisplayingPressingPrompt,
model.isSearchingListenable,
],
builder: (context, final values, child) => !model.isDisplayingPressingPrompt.value
? IconButton(
icon: !model.isSearchingListenable.value
? const Icon(Icons.search)
: const Icon(Icons.close),
onPressed: model.searchPressed,
)
: const SizedBox.shrink(),
),
)
],
),
body: Center(
child: ValueListenableBuilder<bool>(
@ -30,34 +66,27 @@ class GalleryView extends StatelessWidget {
onPressed: model.onPromptPressed,
child: Text(model.strings.startLoadingPrompt),
)
: DecoratedBox(
decoration: const BoxDecoration(color: ConstColours.galleryBackgroundColour),
child: SingleChildScrollView(
// Using Wrap instead of GridView, to make use of different image sizes
child: Wrap(
runSpacing: 24,
spacing: 8,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final imageModel in model.imageModels)
GestureDetector(
onTap: () => model.pushImageCarouselView(
context,
imageModel: imageModel,
: SingleChildScrollView(
child: FutureBuilder<void>(
future: model.initImageFetchIsDone,
builder: (context, final snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
return const CircularProgressIndicator();
case ConnectionState.done:
return ValueListenableBuilder<bool>(
valueListenable: model.isSearchingListenable,
builder: (context, final isSearching, _) => AnimatedSwitcher(
duration: ConstDurations.oneAndHalfDefaultAnimationDuration,
child: !isSearching
? _DownloadedGalleryView(galleryViewModel: model)
: _SearchGalleryView(galleryViewModel: model),
),
child: CachedNetworkImage(
imageUrl: imageModel.uri.toString(),
cacheKey: imageModel.imageIndex.toString(),
progressIndicatorBuilder: (_, __, final progress) =>
CircularProgressIndicator(
value: model.downloadProgressValue(progress: progress),
),
),
),
],
),
);
}
},
),
),
),
@ -67,3 +96,49 @@ class GalleryView extends StatelessWidget {
);
}
}
class _SearchBox extends StatelessWidget {
const _SearchBox({
required this.galleryViewModel,
super.key,
});
final GalleryViewModel galleryViewModel;
@override
Widget build(BuildContext context) {
return Row(
children: [
ValueListenableBuilder<SearchOption>(
valueListenable: galleryViewModel.searchOptionListenable,
builder: (context, final searchOption, _) => Padding(
padding: const EdgeInsets.only(top: 8),
child: DropdownButton<SearchOption>(
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(24),
items: [
for (final searchOption in SearchOption.values)
DropdownMenuItem(
child: Center(child: Text(searchOption.name)),
value: searchOption,
),
],
value: searchOption,
onChanged: galleryViewModel.onSearchOptionChanged,
),
),
),
const Gap(18),
Expanded(
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: galleryViewModel.strings.searchForImage,
),
onChanged: galleryViewModel.onSearchTermUpdate,
),
),
],
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
@ -10,6 +12,7 @@ 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';
class GalleryViewModel extends BaseViewModel {
GalleryViewModel({
@ -30,6 +33,13 @@ class GalleryViewModel extends BaseViewModel {
final ValueNotifier<bool> _isDisplayingPressingPrompt = ValueNotifier(true);
ValueListenable<bool> get isDisplayingPressingPrompt => _isDisplayingPressingPrompt;
final ValueNotifier<bool> _isSearchingNotifier = ValueNotifier(false);
ValueListenable<bool> get isSearchingListenable => _isSearchingNotifier;
final ValueNotifier<SearchOption> _searchOptionNotifier = ValueNotifier(SearchOption.web);
ValueListenable<SearchOption> get searchOptionListenable => _searchOptionNotifier;
final ValueNotifier<List<ImageModel>> _imageSearchResultsNotifier = ValueNotifier([]);
ValueListenable<List<ImageModel>> get imageSearchResultsListenable => _imageSearchResultsNotifier;
@override
Future<void> initialise(bool Function() mounted, [arguments]) async {
super.initialise(mounted, arguments);
@ -40,9 +50,44 @@ class GalleryViewModel extends BaseViewModel {
super.dispose();
}
Future<void> onSearchTermUpdate(String searchTerm) async {
// If empty-string (from backspacing) -> reset state.
if (searchTerm.isEmpty) {
_imageSearchResultsNotifier.value = [];
return;
}
// Detached call to prevent UI blocking
unawaited(_imagesService
.searchImages(
imageNamePart: searchTerm,
searchOption: searchOptionListenable.value,
)
.then(
(final fetchedImageModels) => _imageSearchResultsNotifier.value = fetchedImageModels));
// Force-update to trigger listening to `lastQueryResultDone()`.
_imageSearchResultsNotifier.notifyListeners();
}
void searchPressed() {
// If transitioning from 'Searching', clear previous results immediately
if (_isSearchingNotifier.value) _imageSearchResultsNotifier.value = [];
_isSearchingNotifier.value = !_isSearchingNotifier.value;
}
Future<void> get lastQueryResultDone => _imagesService.lastQueryIsCompleted;
void onSearchOptionChanged(SearchOption? option) => _searchOptionNotifier.value = option!;
void onPromptPressed() => _isDisplayingPressingPrompt.value = false;
Iterable<ImageModel> get imageModels => _imagesService.imageModels;
Future<void> get initImageFetchIsDone => _imagesService.initAwaiter;
double? downloadProgressValue({required DownloadProgress progress}) =>
progress.totalSize != null ? progress.downloaded / progress.totalSize! : null;
void pushImageCarouselView(BuildContext context, {required ImageModel imageModel}) =>
_navigationService.pushImageCarouselView(
@ -53,7 +98,4 @@ class GalleryViewModel extends BaseViewModel {
);
static GalleryViewModel get locate => Locator.locate();
double? downloadProgressValue({required DownloadProgress progress}) =>
progress.totalSize != null ? progress.downloaded / progress.totalSize! : null;
}

View File

@ -0,0 +1,61 @@
part of 'gallery_view.dart';
class _SearchGalleryView extends StatelessWidget {
const _SearchGalleryView({
required this.galleryViewModel,
super.key,
});
final GalleryViewModel galleryViewModel;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<ImageModel>>(
valueListenable: galleryViewModel.imageSearchResultsListenable,
builder: (context, final resultsImageModels, _) => FutureBuilder(
future: galleryViewModel.lastQueryResultDone,
builder: (context, final snapshot) {
final Widget displayedWidget;
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
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,
),
),
),
],
);
}
return AnimatedSwitcher(
duration: ConstDurations.halfDefaultAnimationDuration,
child: displayedWidget,
);
},
),
);
}
}

View File

@ -9,8 +9,8 @@ import '/features/core/data/constants/const_colors.dart';
import '/features/core/data/constants/const_text.dart';
import '/features/core/widgets/gap.dart';
import '/features/core/widgets/mcg_scaffold.dart';
import '/features/core/widgets/view_model_builder.dart';
import '/features/home/views/image_carousel/image_carousel_view_model.dart';
import '../../../core/widgets/state/view_model_builder.dart';
class ImageCarouselViewArguments {
const ImageCarouselViewArguments({required this.imageIndexKey});

View File

@ -20,18 +20,29 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'en';
static String m0(imageNumber, imageSide) =>
"Image ${imageNumber}: size=${imageSide}";
static String m1(searchStr, imageNumber) =>
"Search term \'${searchStr}\' result: Image ${imageNumber}";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"gallery": MessageLookupByLibrary.simpleMessage("Gallery"),
"image": MessageLookupByLibrary.simpleMessage("Image"),
"imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"),
"imageDetails": MessageLookupByLibrary.simpleMessage(
"Lorem ipsum dolor sit amet. A odio aliquam est sunt explicabo cum galisum asperiores qui voluptas tempora qui aliquid similique. Ut quam laborum ex nostrum recusandae ab sunt ratione quo tempore corporis 33 voluptas nulla aut obcaecati perspiciatis.\n\nAd eveniet exercitationem ad odit quidem aut omnis corporis ea nulla illum qui quisquam temporibus? Est obcaecati similique et quisquam unde ea impedit mollitia ea accusamus natus hic doloribus quis! Et dolorem rerum id doloribus sint ea porro quia ut reprehenderit ratione?"),
"imageNameFetch": m0,
"imageNameSearch": m1,
"local": MessageLookupByLibrary.simpleMessage("Local"),
"noInternetMessage": MessageLookupByLibrary.simpleMessage(
"Are you sure that you\'re connected to the internet?"),
"searchForImage":
MessageLookupByLibrary.simpleMessage("Search for your image"),
"somethingWentWrong":
MessageLookupByLibrary.simpleMessage("Something went wrong"),
"startLoadingPrompt":
MessageLookupByLibrary.simpleMessage("Press me to start loading")
MessageLookupByLibrary.simpleMessage("Press me to start loading"),
"web": MessageLookupByLibrary.simpleMessage("Web")
};
}

View File

@ -60,13 +60,23 @@ class Strings {
);
}
/// `Image`
String get image {
/// `Image {imageNumber}: size={imageSide}`
String imageNameFetch(Object imageNumber, Object imageSide) {
return Intl.message(
'Image',
name: 'image',
'Image $imageNumber: size=$imageSide',
name: 'imageNameFetch',
desc: '',
args: [],
args: [imageNumber, imageSide],
);
}
/// `Search term '{searchStr}' result: Image {imageNumber}`
String imageNameSearch(Object searchStr, Object imageNumber) {
return Intl.message(
'Search term \'$searchStr\' result: Image $imageNumber',
name: 'imageNameSearch',
desc: '',
args: [searchStr, imageNumber],
);
}
@ -90,6 +100,36 @@ class Strings {
);
}
/// `Search for your image`
String get searchForImage {
return Intl.message(
'Search for your image',
name: 'searchForImage',
desc: '',
args: [],
);
}
/// `Local`
String get local {
return Intl.message(
'Local',
name: 'local',
desc: '',
args: [],
);
}
/// `Web`
String get web {
return Intl.message(
'Web',
name: 'web',
desc: '',
args: [],
);
}
/// `Image carousel`
String get imageCarousel {
return Intl.message(

View File

@ -2,10 +2,14 @@
"@@locale": "en",
"somethingWentWrong": "Something went wrong",
"image": "Image",
"imageNameFetch": "Image {imageNumber}: size={imageSide}",
"imageNameSearch": "Search term '{searchStr}' result: Image {imageNumber}",
"gallery": "Gallery",
"startLoadingPrompt": "Press me to start loading",
"searchForImage": "Search for your image",
"local": "Local",
"web": "Web",
"imageCarousel": "Image carousel",
"imageDetails": "Lorem ipsum dolor sit amet. A odio aliquam est sunt explicabo cum galisum asperiores qui voluptas tempora qui aliquid similique. Ut quam laborum ex nostrum recusandae ab sunt ratione quo tempore corporis 33 voluptas nulla aut obcaecati perspiciatis.\n\nAd eveniet exercitationem ad odit quidem aut omnis corporis ea nulla illum qui quisquam temporibus? Est obcaecati similique et quisquam unde ea impedit mollitia ea accusamus natus hic doloribus quis! Et dolorem rerum id doloribus sint ea porro quia ut reprehenderit ratione?",

View File

@ -96,9 +96,8 @@ class Locator {
it.registerSingleton<ImagesService>(
ImagesService(imagesApi: UnsplashImagesApi.locate, loggingService: LoggingService.locate),
signalsReady: true,
);
await it.isReady<ImagesService>();
//await it.isReady<ImagesService>();
it.registerSingleton(
ImageCacheManagerService(