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

@ -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});