live search
This commit is contained in:
parent
730db86b80
commit
aed02b302b
17 changed files with 471 additions and 51 deletions
|
@ -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:flutter/widgets.dart';
|
||||||
import 'package:provider/provider.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 {
|
class ViewModelBuilder<T extends BaseViewModel> extends StatefulWidget {
|
||||||
const ViewModelBuilder({
|
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.
|
/// and convoluting) interface is for adding a bit of flexibility to change strategy to some other site.
|
||||||
abstract class ImagesApi {
|
abstract class ImagesApi {
|
||||||
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token});
|
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token});
|
||||||
|
|
||||||
|
FutureOr<List<ImageModel>> searchImages({
|
||||||
|
required String searchStr,
|
||||||
|
required String token,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:mc_gallery/features/core/data/extensions/random_extensions.dart';
|
||||||
import 'package:mc_gallery/features/core/services/logging_service.dart';
|
import 'package:mc_gallery/features/core/services/logging_service.dart';
|
||||||
import 'package:mc_gallery/locator.dart';
|
import 'package:mc_gallery/locator.dart';
|
||||||
|
|
||||||
|
@ -9,31 +10,65 @@ import '/l10n/generated/l10n.dart';
|
||||||
import '../abstracts/images_api.dart';
|
import '../abstracts/images_api.dart';
|
||||||
import '../data/models/image_model.dart';
|
import '../data/models/image_model.dart';
|
||||||
|
|
||||||
class UnsplashImagesApi with LoggingService implements ImagesApi {
|
class UnsplashImagesApi implements ImagesApi {
|
||||||
@override
|
final LoggingService _loggingService = LoggingService.locate;
|
||||||
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token}) {
|
final random = Random();
|
||||||
final random = Random();
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<Iterable<ImageModel>> fetchImageUri({required String token}) async {
|
||||||
try {
|
try {
|
||||||
|
// Create fixed number of images
|
||||||
return Iterable<int>.generate(ConstValues.numberOfImages).map((final imageIndex) {
|
return Iterable<int>.generate(ConstValues.numberOfImages).map((final imageIndex) {
|
||||||
// Drawing from a normal distribution
|
// Drawing from a normal distribution
|
||||||
final imageSide = ConstValues.minImageSize +
|
final imageSide =
|
||||||
random.nextInt((ConstValues.maxImageSize + 1) - ConstValues.minImageSize);
|
random.nextIntInRange(min: ConstValues.minImageSize, max: ConstValues.maxImageSize);
|
||||||
|
|
||||||
final imageUri = _imageUrlGenerator(imageSide: imageSide);
|
final imageUri = _imageUrlGenerator(imageSide: imageSide);
|
||||||
|
|
||||||
return ImageModel(
|
return ImageModel(
|
||||||
imageIndex: imageIndex,
|
imageIndex: imageIndex,
|
||||||
uri: imageUri,
|
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) {
|
} on Exception catch (ex, stackTrace) {
|
||||||
handleException(ex, stackTrace);
|
_loggingService.handleException(ex, stackTrace);
|
||||||
return const Iterable.empty();
|
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);
|
||||||
|
await Future.delayed(Duration(milliseconds: 250 * 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(
|
Uri _imageUrlGenerator({required int imageSide}) => Uri(
|
||||||
scheme: ConstValues.httpsScheme,
|
scheme: ConstValues.httpsScheme,
|
||||||
host: ConstValues.backendHost,
|
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,13 @@
|
||||||
import 'package:mc_gallery/features/core/services/logging_service.dart';
|
import 'dart:async';
|
||||||
import 'package:mc_gallery/features/home/data/models/image_model.dart';
|
|
||||||
import 'package:mc_gallery/locator.dart';
|
|
||||||
|
|
||||||
|
import 'package:mc_gallery/features/core/utils/mutex.dart';
|
||||||
|
|
||||||
|
import '/features/core/data/extensions/iterable_extensions.dart';
|
||||||
|
import '/features/core/services/logging_service.dart';
|
||||||
|
import '/features/home/data/models/image_model.dart';
|
||||||
|
import '/locator.dart';
|
||||||
import '../abstracts/images_api.dart';
|
import '../abstracts/images_api.dart';
|
||||||
|
import '../data/enums/search_option.dart';
|
||||||
|
|
||||||
/// Handles fetching and storing of Images.
|
/// Handles fetching and storing of Images.
|
||||||
///
|
///
|
||||||
|
@ -21,7 +26,8 @@ class ImagesService {
|
||||||
final LoggingService _loggingService;
|
final LoggingService _loggingService;
|
||||||
|
|
||||||
late final Iterable<ImageModel> _imageModels;
|
late final Iterable<ImageModel> _imageModels;
|
||||||
Iterable<ImageModel> get imageModels => _imageModels;
|
Iterable<ImageModel> get imageModels => _imageModels.deepCopy;
|
||||||
|
final Mutex _searchMutex = Mutex();
|
||||||
|
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
_loggingService.info('Fetching and creating image models...');
|
_loggingService.info('Fetching and creating image models...');
|
||||||
|
@ -40,5 +46,28 @@ class ImagesService {
|
||||||
|
|
||||||
ImageModel imageModelAt({required int index}) => _imageModels.elementAt(index);
|
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();
|
static ImagesService get locate => Locator.locate();
|
||||||
}
|
}
|
||||||
|
|
43
lib/features/home/views/gallery/downloaded_gallery_view.dart
Normal file
43
lib/features/home/views/gallery/downloaded_gallery_view.dart
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
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),
|
||||||
|
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_colors.dart';
|
||||||
import '/features/core/data/constants/const_durations.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/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';
|
import 'gallery_view_model.dart';
|
||||||
|
|
||||||
|
part 'downloaded_gallery_view.dart';
|
||||||
|
part 'search_gallery_view.dart';
|
||||||
|
|
||||||
class GalleryView extends StatelessWidget {
|
class GalleryView extends StatelessWidget {
|
||||||
const GalleryView({super.key});
|
const GalleryView({super.key});
|
||||||
|
|
||||||
|
@ -18,7 +25,36 @@ class GalleryView extends StatelessWidget {
|
||||||
bodyBuilderWaiter: model.isInitialised,
|
bodyBuilderWaiter: model.isInitialised,
|
||||||
forceInternetCheck: true,
|
forceInternetCheck: true,
|
||||||
appBar: AppBar(
|
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(
|
body: Center(
|
||||||
child: ValueListenableBuilder<bool>(
|
child: ValueListenableBuilder<bool>(
|
||||||
|
@ -30,33 +66,15 @@ class GalleryView extends StatelessWidget {
|
||||||
onPressed: model.onPromptPressed,
|
onPressed: model.onPromptPressed,
|
||||||
child: Text(model.strings.startLoadingPrompt),
|
child: Text(model.strings.startLoadingPrompt),
|
||||||
)
|
)
|
||||||
: DecoratedBox(
|
: SingleChildScrollView(
|
||||||
decoration: const BoxDecoration(color: ConstColours.galleryBackgroundColour),
|
// Using Wrap instead of GridView, to make use of different image sizes
|
||||||
child: SingleChildScrollView(
|
child: ValueListenableBuilder<bool>(
|
||||||
// Using Wrap instead of GridView, to make use of different image sizes
|
valueListenable: model.isSearchingListenable,
|
||||||
child: Wrap(
|
builder: (context, final isSearching, _) => AnimatedSwitcher(
|
||||||
runSpacing: 24,
|
duration: ConstDurations.oneAndHalfDefaultAnimationDuration,
|
||||||
spacing: 8,
|
child: !isSearching
|
||||||
alignment: WrapAlignment.center,
|
? _DownloadedGalleryView(galleryViewModel: model)
|
||||||
runAlignment: WrapAlignment.center,
|
: _SearchGalleryView(galleryViewModel: model),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -67,3 +85,48 @@ 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(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: galleryViewModel.strings.searchForImage,
|
||||||
|
),
|
||||||
|
onChanged: galleryViewModel.onSearchTermUpdate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.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/services/images_service.dart';
|
||||||
import '/features/home/views/image_carousel/image_carousel_view.dart';
|
import '/features/home/views/image_carousel/image_carousel_view.dart';
|
||||||
import '/locator.dart';
|
import '/locator.dart';
|
||||||
|
import '../../data/enums/search_option.dart';
|
||||||
|
|
||||||
class GalleryViewModel extends BaseViewModel {
|
class GalleryViewModel extends BaseViewModel {
|
||||||
GalleryViewModel({
|
GalleryViewModel({
|
||||||
|
@ -30,6 +33,13 @@ class GalleryViewModel extends BaseViewModel {
|
||||||
final ValueNotifier<bool> _isDisplayingPressingPrompt = ValueNotifier(true);
|
final ValueNotifier<bool> _isDisplayingPressingPrompt = ValueNotifier(true);
|
||||||
ValueListenable<bool> get isDisplayingPressingPrompt => _isDisplayingPressingPrompt;
|
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
|
@override
|
||||||
Future<void> initialise(bool Function() mounted, [arguments]) async {
|
Future<void> initialise(bool Function() mounted, [arguments]) async {
|
||||||
super.initialise(mounted, arguments);
|
super.initialise(mounted, arguments);
|
||||||
|
@ -40,6 +50,37 @@ class GalleryViewModel extends BaseViewModel {
|
||||||
super.dispose();
|
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;
|
void onPromptPressed() => _isDisplayingPressingPrompt.value = false;
|
||||||
|
|
||||||
Iterable<ImageModel> get imageModels => _imagesService.imageModels;
|
Iterable<ImageModel> get imageModels => _imagesService.imageModels;
|
||||||
|
|
63
lib/features/home/views/gallery/search_gallery_view.dart
Normal file
63
lib/features/home/views/gallery/search_gallery_view.dart
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
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) {
|
||||||
|
debugPrint(
|
||||||
|
'--------------------\nBuilding with${resultsImageModels.length} results.\nStatus: ${snapshot.connectionState}\n-------------------');
|
||||||
|
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/data/constants/const_text.dart';
|
||||||
import '/features/core/widgets/gap.dart';
|
import '/features/core/widgets/gap.dart';
|
||||||
import '/features/core/widgets/mcg_scaffold.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 '/features/home/views/image_carousel/image_carousel_view_model.dart';
|
||||||
|
import '../../../core/widgets/state/view_model_builder.dart';
|
||||||
|
|
||||||
class ImageCarouselViewArguments {
|
class ImageCarouselViewArguments {
|
||||||
const ImageCarouselViewArguments({required this.imageIndexKey});
|
const ImageCarouselViewArguments({required this.imageIndexKey});
|
||||||
|
|
|
@ -20,18 +20,29 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||||
class MessageLookup extends MessageLookupByLibrary {
|
class MessageLookup extends MessageLookupByLibrary {
|
||||||
String get localeName => 'en';
|
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);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
"gallery": MessageLookupByLibrary.simpleMessage("Gallery"),
|
"gallery": MessageLookupByLibrary.simpleMessage("Gallery"),
|
||||||
"image": MessageLookupByLibrary.simpleMessage("Image"),
|
|
||||||
"imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"),
|
"imageCarousel": MessageLookupByLibrary.simpleMessage("Image carousel"),
|
||||||
"imageDetails": MessageLookupByLibrary.simpleMessage(
|
"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?"),
|
"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(
|
"noInternetMessage": MessageLookupByLibrary.simpleMessage(
|
||||||
"Are you sure that you\'re connected to the internet?"),
|
"Are you sure that you\'re connected to the internet?"),
|
||||||
|
"searchForImage":
|
||||||
|
MessageLookupByLibrary.simpleMessage("Search for your image"),
|
||||||
"somethingWentWrong":
|
"somethingWentWrong":
|
||||||
MessageLookupByLibrary.simpleMessage("Something went wrong"),
|
MessageLookupByLibrary.simpleMessage("Something went wrong"),
|
||||||
"startLoadingPrompt":
|
"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`
|
/// `Image {imageNumber}: size={imageSide}`
|
||||||
String get image {
|
String imageNameFetch(Object imageNumber, Object imageSide) {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
'Image',
|
'Image $imageNumber: size=$imageSide',
|
||||||
name: 'image',
|
name: 'imageNameFetch',
|
||||||
desc: '',
|
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`
|
/// `Image carousel`
|
||||||
String get imageCarousel {
|
String get imageCarousel {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
|
|
|
@ -2,10 +2,14 @@
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"somethingWentWrong": "Something went wrong",
|
"somethingWentWrong": "Something went wrong",
|
||||||
|
|
||||||
"image": "Image",
|
"imageNameFetch": "Image {imageNumber}: size={imageSide}",
|
||||||
|
"imageNameSearch": "Search term '{searchStr}' result: Image {imageNumber}",
|
||||||
|
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
"startLoadingPrompt": "Press me to start loading",
|
"startLoadingPrompt": "Press me to start loading",
|
||||||
|
"searchForImage": "Search for your image",
|
||||||
|
"local": "Local",
|
||||||
|
"web": "Web",
|
||||||
|
|
||||||
"imageCarousel": "Image carousel",
|
"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?",
|
"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?",
|
||||||
|
|
Loading…
Reference in a new issue