From ac5531ced3e8f63074c0113814a8a7f6f96a73b2 Mon Sep 17 00:00:00 2001 From: Mehul Ahal <112100480+MehulAhal@users.noreply.github.com> Date: Fri, 23 Dec 2022 11:20:46 +0100 Subject: [PATCH] live search --- .../data/extensions/iterable_extensions.dart | 3 + .../state/multi_value_listenable_builder.dart | 34 +++++ .../{ => state}/view_model_builder.dart | 2 +- .../home/services/images_service.dart | 18 ++- .../home/views/gallery/gallery_view.dart | 141 ++++++++++++++---- .../views/gallery/gallery_view_model.dart | 25 ++++ .../image_carousel/image_carousel_view.dart | 2 +- lib/l10n/generated/intl/messages_en.dart | 6 +- lib/l10n/generated/l10n.dart | 30 ++++ lib/l10n/intl_en.arb | 3 + 10 files changed, 231 insertions(+), 33 deletions(-) create mode 100644 lib/features/core/data/extensions/iterable_extensions.dart create mode 100644 lib/features/core/widgets/state/multi_value_listenable_builder.dart rename lib/features/core/widgets/{ => state}/view_model_builder.dart (96%) diff --git a/lib/features/core/data/extensions/iterable_extensions.dart b/lib/features/core/data/extensions/iterable_extensions.dart new file mode 100644 index 0000000..69845c8 --- /dev/null +++ b/lib/features/core/data/extensions/iterable_extensions.dart @@ -0,0 +1,3 @@ +extension IterableExtensions on Iterable { + Iterable get deepCopy => toList(growable: false); +} diff --git a/lib/features/core/widgets/state/multi_value_listenable_builder.dart b/lib/features/core/widgets/state/multi_value_listenable_builder.dart new file mode 100644 index 0000000..fb8be33 --- /dev/null +++ b/lib/features/core/widgets/state/multi_value_listenable_builder.dart @@ -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 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 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.unmodifiable(providedValues), child); + }, + child: child, + ); + } +} diff --git a/lib/features/core/widgets/view_model_builder.dart b/lib/features/core/widgets/state/view_model_builder.dart similarity index 96% rename from lib/features/core/widgets/view_model_builder.dart rename to lib/features/core/widgets/state/view_model_builder.dart index 66de637..f503dd0 100644 --- a/lib/features/core/widgets/view_model_builder.dart +++ b/lib/features/core/widgets/state/view_model_builder.dart @@ -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 extends StatefulWidget { const ViewModelBuilder({ diff --git a/lib/features/home/services/images_service.dart b/lib/features/home/services/images_service.dart index ce04a3a..338e064 100644 --- a/lib/features/home/services/images_service.dart +++ b/lib/features/home/services/images_service.dart @@ -1,7 +1,7 @@ -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 '/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'; /// Handles fetching and storing of Images. @@ -21,7 +21,7 @@ class ImagesService { final LoggingService _loggingService; late final Iterable _imageModels; - Iterable get imageModels => _imageModels; + Iterable get imageModels => _imageModels.deepCopy; Future _init() async { _loggingService.info('Fetching and creating image models...'); @@ -40,5 +40,13 @@ class ImagesService { ImageModel imageModelAt({required int index}) => _imageModels.elementAt(index); + List searchLocal({required String imageNamePart}) { + return []; + } + + List searchApi({required String imageNamePart}) { + return []; + } + static ImagesService get locate => Locator.locate(); } diff --git a/lib/features/home/views/gallery/gallery_view.dart b/lib/features/home/views/gallery/gallery_view.dart index b517f53..63236f7 100644 --- a/lib/features/home/views/gallery/gallery_view.dart +++ b/lib/features/home/views/gallery/gallery_view.dart @@ -1,10 +1,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:mc_gallery/features/core/widgets/gap.dart'; import '/features/core/data/constants/const_colors.dart'; import '/features/core/data/constants/const_durations.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 'gallery_view_model.dart'; class GalleryView extends StatelessWidget { @@ -18,7 +20,39 @@ class GalleryView extends StatelessWidget { bodyBuilderWaiter: model.isInitialised, forceInternetCheck: true, appBar: AppBar( - title: Text(model.strings.gallery), + centerTitle: true, + primary: true, + title: ValueListenableBuilder( + 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: IconButton( + icon: MultiValueListenableBuilder( + valueListenables: [ + model.isDisplayingPressingPrompt, + model.isSearchingListenable, + ], + builder: (context, final values, child) => AnimatedSwitcher( + duration: ConstDurations.oneAndHalfDefaultAnimationDuration, + child: !model.isDisplayingPressingPrompt.value + ? !model.isSearchingListenable.value + ? const Icon(Icons.search) + : const Icon(Icons.close) + : const SizedBox.shrink(), + ), + ), + onPressed: model.searchPressed, + ), + ) + ], ), body: Center( child: ValueListenableBuilder( @@ -34,29 +68,38 @@ class GalleryView extends StatelessWidget { 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), - ), - ), - ), - ], + child: ValueListenableBuilder( + valueListenable: model.isSearchingListenable, + builder: (context, final isSearching, _) => AnimatedSwitcher( + duration: ConstDurations.doubleDefaultAnimationDuration, + child: !isSearching + ? 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), + ), + ), + ), + ], + ) + : const Placeholder(), + ), ), ), ), @@ -67,3 +110,51 @@ class GalleryView extends StatelessWidget { ); } } + +class _SearchBox extends StatelessWidget { + _SearchBox({ + required this.galleryViewModel, + super.key, + }); + + final GalleryViewModel galleryViewModel; + + final TextEditingController _searchController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + ValueListenableBuilder( + valueListenable: galleryViewModel.searchOptionListenable, + builder: (context, final searchOption, _) => Padding( + padding: const EdgeInsets.only(top: 8), + child: DropdownButton( + 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( + controller: _searchController, + decoration: InputDecoration( + hintText: galleryViewModel.strings.searchForImage, + ), + onChanged: (final searchPart) {}, + ), + ), + ], + ); + } +} diff --git a/lib/features/home/views/gallery/gallery_view_model.dart b/lib/features/home/views/gallery/gallery_view_model.dart index 4e213d8..5abb368 100644 --- a/lib/features/home/views/gallery/gallery_view_model.dart +++ b/lib/features/home/views/gallery/gallery_view_model.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:mc_gallery/l10n/generated/l10n.dart'; import '/features/core/abstracts/base_view_model.dart'; import '/features/core/services/logging_service.dart'; @@ -30,6 +31,14 @@ class GalleryViewModel extends BaseViewModel { final ValueNotifier _isDisplayingPressingPrompt = ValueNotifier(true); ValueListenable get isDisplayingPressingPrompt => _isDisplayingPressingPrompt; + final ValueNotifier _isSearchingNotifier = ValueNotifier(false); + ValueListenable get isSearchingListenable => _isSearchingNotifier; + + final ValueNotifier _searchOptionNotifier = ValueNotifier(SearchOption.local); + ValueListenable get searchOptionListenable => _searchOptionNotifier; + final ValueNotifier> _imageSearchResultsNotifier = ValueNotifier([]); + ValueListenable> get imageSearchResultsNotifier => _imageSearchResultsNotifier; + @override Future initialise(bool Function() mounted, [arguments]) async { super.initialise(mounted, arguments); @@ -41,6 +50,8 @@ class GalleryViewModel extends BaseViewModel { } void onPromptPressed() => _isDisplayingPressingPrompt.value = false; + void searchPressed() => _isSearchingNotifier.value = !_isSearchingNotifier.value; + void onSearchOptionChanged(SearchOption? option) => _searchOptionNotifier.value = option!; Iterable get imageModels => _imagesService.imageModels; @@ -57,3 +68,17 @@ class GalleryViewModel extends BaseViewModel { double? downloadProgressValue({required DownloadProgress progress}) => progress.totalSize != null ? progress.downloaded / progress.totalSize! : null; } + +enum SearchOption { + local, + web; + + String get name { + switch (this) { + case SearchOption.local: + return Strings.current.local; + case SearchOption.web: + return Strings.current.web; + } + } +} diff --git a/lib/features/home/views/image_carousel/image_carousel_view.dart b/lib/features/home/views/image_carousel/image_carousel_view.dart index 8c83f1b..051f4c3 100644 --- a/lib/features/home/views/image_carousel/image_carousel_view.dart +++ b/lib/features/home/views/image_carousel/image_carousel_view.dart @@ -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}); diff --git a/lib/l10n/generated/intl/messages_en.dart b/lib/l10n/generated/intl/messages_en.dart index 46b3ea6..950b379 100644 --- a/lib/l10n/generated/intl/messages_en.dart +++ b/lib/l10n/generated/intl/messages_en.dart @@ -27,11 +27,15 @@ class MessageLookup extends MessageLookupByLibrary { "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?"), + "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") }; } diff --git a/lib/l10n/generated/l10n.dart b/lib/l10n/generated/l10n.dart index ec0ad09..e353cf9 100644 --- a/lib/l10n/generated/l10n.dart +++ b/lib/l10n/generated/l10n.dart @@ -90,6 +90,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( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 9f04702..c9d481f 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -6,6 +6,9 @@ "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?",