live WEB search
This commit is contained in:
parent
47945dbec7
commit
4ade7f1682
19 changed files with 503 additions and 59 deletions
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,
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue