live WEB search
This commit is contained in:
parent
730db86b80
commit
c34d4f23ae
19 changed files with 503 additions and 59 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
extension IterableExtensions<T> on Iterable<T> {
|
||||
Iterable<T> get deepCopy => toList(growable: false);
|
||||
}
|
9
lib/features/core/data/extensions/random_extensions.dart
Normal file
9
lib/features/core/data/extensions/random_extensions.dart
Normal 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);
|
||||
}
|
25
lib/features/core/utils/mutex.dart
Normal file
25
lib/features/core/utils/mutex.dart
Normal 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();
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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({
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
@override
|
||||
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token}) {
|
||||
class UnsplashImagesApi implements ImagesApi {
|
||||
final LoggingService _loggingService = LoggingService.locate;
|
||||
final random = Random();
|
||||
|
||||
@override
|
||||
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,
|
||||
|
|
15
lib/features/home/data/enums/search_option.dart
Normal file
15
lib/features/home/data/enums/search_option.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
44
lib/features/home/views/gallery/downloaded_gallery_view.dart
Normal file
44
lib/features/home/views/gallery/downloaded_gallery_view.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageModel.uri.toString(),
|
||||
cacheKey: imageModel.imageIndex.toString(),
|
||||
progressIndicatorBuilder: (_, __, final progress) =>
|
||||
CircularProgressIndicator(
|
||||
value: model.downloadProgressValue(progress: progress),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
: 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
61
lib/features/home/views/gallery/search_gallery_view.dart
Normal file
61
lib/features/home/views/gallery/search_gallery_view.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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});
|
||||
|
|
|
@ -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")
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue