diff --git a/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_grid.dart b/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_grid.dart index 030c2c65aa80..84f25900399d 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_grid.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_grid.dart @@ -36,28 +36,33 @@ class SmoothImagesSliverGrid extends SmoothImagesView { padding: const EdgeInsets.all(MEDIUM_SPACE), sliver: SliverGrid( delegate: LoadingSliverChildBuilderDelegate( - loading: loading, - childCount: imageList.length, - loadingWidget: _buildShimmer(), - loadingCount: loadingCount, - childBuilder: (BuildContext context, int index) { - final MapEntry?> entry = - imageList[index]; - final ImageProvider? imageProvider = entry.value; - final String? imageUrl = entry.key.imageUrl; + loading: loading, + childCount: imageList.length, + loadingWidget: _buildShimmer(), + loadingCount: loadingCount, + childBuilder: (BuildContext context, int index) { + final MapEntry?> entry = + imageList[index]; + final ImageProvider? imageProvider = entry.value; + final String? imageUrl = entry.key.imageUrl; - return imageProvider == null || imageUrl == null - ? const PictureNotFound() - : Hero( - tag: imageUrl, - child: _ImageTile( - image: imageProvider, - onTap: onTap == null - ? null - : () => onTap!(entry.key, entry.value), - ), - ); - }), + return imageProvider == null || imageUrl == null + ? const PictureNotFound() + : Hero( + tag: imageUrl, + child: _ImageTile( + image: imageProvider, + onTap: onTap == null + ? null + : () => onTap!( + entry.key, + entry.value, + null, + ), + ), + ); + }, + ), gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxTileWidth, childAspectRatio: childAspectRatio, diff --git a/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_list.dart b/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_list.dart index 3a421d18dc38..48418dec7805 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_list.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_sliver_list.dart @@ -36,7 +36,11 @@ class SmoothImagesSliverList extends SmoothImagesView { ), onTap: onTap == null ? null - : () => onTap!(imageList[index].key, imageList[index].value), + : () => onTap!( + imageList[index].key, + imageList[index].value, + index, + ), ), ), ); diff --git a/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_view.dart b/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_view.dart index 9b5f88cf5b67..4f58b51af272 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_view.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/images/smooth_images_view.dart @@ -10,6 +10,6 @@ abstract class SmoothImagesView extends StatelessWidget { }); final Map imagesData; - final void Function(ProductImageData, ImageProvider?)? onTap; + final void Function(ProductImageData, ImageProvider?, int?)? onTap; final bool loading; } diff --git a/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart b/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart index 4e1ec55c7759..e81a23c9b713 100644 --- a/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart +++ b/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart @@ -1,17 +1,19 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/product_image_data.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/database/transient_file.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/images/smooth_images_sliver_list.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; -import 'package:smooth_app/pages/product/product_image_viewer.dart'; +import 'package:smooth_app/pages/product/confirm_and_upload_picture.dart'; +import 'package:smooth_app/pages/product/product_image_swipeable_view.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; @@ -32,14 +34,11 @@ class _ProductImageGalleryViewState extends State { late final LocalDatabase _localDatabase; late final Product _initialProduct; late Product _product; - late Map _selectedImages; - + bool _isRefreshed = false; ImageProvider? _provideImage(ProductImageData imageData) => - TransientFile.getImageProvider(imageData, _barcode); - + imageData.imageUrl == null ? null : NetworkImage(imageData.imageUrl!); String get _barcode => _initialProduct.barcode!; - @override void initState() { super.initState(); @@ -80,7 +79,7 @@ class _ProductImageGalleryViewState extends State { ) : null, leading: SmoothBackButton( - onPressed: () => Navigator.maybePop(context), + onPressed: () => Navigator.maybePop(context, _isRefreshed), ), ), body: RefreshIndicator( @@ -97,9 +96,16 @@ class _ProductImageGalleryViewState extends State { ), SmoothImagesSliverList( imagesData: _selectedImages, - onTap: (ProductImageData data, _) => - TransientFile.isImageAvailable(data, _barcode) - ? _openImage(data) + onTap: ( + ProductImageData data, + _, + int? currentProductImageDataIndex, + ) => + data.imageUrl != null + ? _openImage( + selectedImages: _selectedImages, + index: currentProductImageDataIndex ?? 0, + ) : _newImage(data), ), ], @@ -119,22 +125,54 @@ class _ProductImageGalleryViewState extends State { ), ), ); - - Future _openImage(ProductImageData imageData) async => - Navigator.push( + Future _openImage({ + required int index, + required Map selectedImages, + }) async => + Navigator.push( context, MaterialPageRoute( - builder: (_) => ProductImageViewer( - product: _product, - imageField: imageData.imageField, - ), + builder: (_) { + return ProductImageSwipeableView( + barcode: _barcode, + initialProductImageDataIndex: index, + selectedImages: _selectedImages, + ); + }, ), ); + Future _newImage(ProductImageData data) async { + final File? croppedImageFile = await startNewImageCropping(this); + if (croppedImageFile == null) { + return; + } + if (!mounted) { + return; + } + setState(() { + final FileImage fileImage = FileImage(croppedImageFile); + final ImageField imageField = data.imageField; + for (final ProductImageData productImageData in _selectedImages.keys) { + if (productImageData.imageField == imageField) { + _selectedImages[productImageData] = fileImage; + return; + } + } + }); + final File? uploaded = await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => ConfirmAndUploadPicture( + barcode: _barcode, + imageField: data.imageField, + initialPhoto: croppedImageFile, + ), + ), + ); + final bool isUploaded = uploaded != null; - Future _newImage(ProductImageData data) async => - confirmAndUploadNewPicture( - this, - barcode: _barcode, - imageField: data.imageField, - ); + if (isUploaded) { + _isRefreshed = true; + } + } } diff --git a/packages/smooth_app/lib/pages/product/product_image_swipeable_view.dart b/packages/smooth_app/lib/pages/product/product_image_swipeable_view.dart new file mode 100644 index 000000000000..9e3ef4adc22e --- /dev/null +++ b/packages/smooth_app/lib/pages/product/product_image_swipeable_view.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:smooth_app/data_models/product_image_data.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart'; +import 'package:smooth_app/pages/product/product_image_viewer.dart'; + +///Widget to display swipeable product images, +///Opens product image with [initialProductImageDataIndex] from list of images Typecasted from [selectedImages] +class ProductImageSwipeableView extends StatefulWidget { + const ProductImageSwipeableView({ + super.key, + required this.selectedImages, + required this.initialProductImageDataIndex, + required this.barcode, + }); + + final Map selectedImages; + final int initialProductImageDataIndex; + final String barcode; + + @override + State createState() => + _ProductImageSwipeableViewState(); +} + +class _ProductImageSwipeableViewState extends State { + final ValueNotifier _currentImageDataIndex = ValueNotifier(0); + late List _imageDataList; + late PageController _controller; + + @override + void initState() { + super.initState(); + _currentImageDataIndex.value = widget.initialProductImageDataIndex; + _imageDataList = List.from(widget.selectedImages.keys); + _controller = PageController( + initialPage: widget.initialProductImageDataIndex, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: WHITE_COLOR, + elevation: 0, + title: ValueListenableBuilder( + valueListenable: _currentImageDataIndex, + builder: (_, int index, __) { + return Text( + _imageDataList[index].title, + ); + }, + ), + leading: SmoothBackButton( + iconColor: Colors.white, + onPressed: () => Navigator.maybePop(context), + ), + ), + body: PageView.builder( + onPageChanged: (int index) { + _currentImageDataIndex.value = index; + }, + controller: _controller, + itemCount: widget.selectedImages.keys.length, + itemBuilder: (BuildContext context, int index) { + return ProductImageViewer( + barcode: widget.barcode, + imageData: _imageDataList[index], + ); + }, + ), + ); + } +} diff --git a/packages/smooth_app/lib/pages/product/product_image_viewer.dart b/packages/smooth_app/lib/pages/product/product_image_viewer.dart index 6d2a80d936b6..ebdbf6a604f6 100644 --- a/packages/smooth_app/lib/pages/product/product_image_viewer.dart +++ b/packages/smooth_app/lib/pages/product/product_image_viewer.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http/http.dart' as http; -import 'package:openfoodfacts/model/Product.dart'; import 'package:openfoodfacts/model/ProductImage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_view/photo_view.dart'; @@ -11,173 +10,164 @@ import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/product_image_data.dart'; import 'package:smooth_app/database/dao_int.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/database/transient_file.dart'; -import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/loading_dialog.dart'; -import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart'; +import 'package:smooth_app/generic_lib/widgets/picture_not_found.dart'; import 'package:smooth_app/helpers/database_helper.dart'; -import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; import 'package:smooth_app/pages/product/confirm_and_upload_picture.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; -/// Displays a full-screen image with an "edit" floating button. +/// Displays a full-screen image with an edit floating button class ProductImageViewer extends StatefulWidget { const ProductImageViewer({ - required this.product, - required this.imageField, + required this.barcode, + required this.imageData, }); - final Product product; - final ImageField imageField; + final String barcode; + final ProductImageData imageData; @override State createState() => _ProductImageViewerState(); } class _ProductImageViewerState extends State { - late Product _product; - late final Product _initialProduct; - late final LocalDatabase _localDatabase; - late ProductImageData _imageData; + late final AppLocalizations _appLocalizations = AppLocalizations.of(context); - String get _barcode => _initialProduct.barcode!; + /// When the image is edited, this is the new image + ImageProvider? _imageProvider; + late bool _isImageUrlAvailable; @override void initState() { super.initState(); - _initialProduct = widget.product; - _localDatabase = context.read(); - _localDatabase.upToDate.showInterest(_barcode); - } - - @override - void dispose() { - _localDatabase.upToDate.loseInterest(_barcode); - super.dispose(); + _isImageUrlAvailable = widget.imageData.imageUrl != null; + if (_isImageUrlAvailable) { + _imageProvider = NetworkImage( + widget.imageData.imageUrl!, + ); + } } @override - Widget build(BuildContext context) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - context.watch(); - _product = _localDatabase.upToDate.getLocalUpToDate(_initialProduct); - _imageData = getProductImageData( - _product, - appLocalizations, - widget.imageField, - ); - final ImageProvider? imageProvider = TransientFile.getImageProvider( - _imageData, - _barcode, - ); - return SmoothScaffold( - extendBodyBehindAppBar: true, - backgroundColor: Colors.black, - floatingActionButton: FloatingActionButton.extended( - label: Text(appLocalizations.edit_photo_button_label), - icon: const Icon(Icons.edit), - backgroundColor: Theme.of(context).colorScheme.primary, - onPressed: () async => _editImage(), - ), - appBar: AppBar( + Widget build(BuildContext context) => SmoothScaffold( + extendBodyBehindAppBar: true, backgroundColor: Colors.black, - foregroundColor: WHITE_COLOR, - elevation: 0, - title: Text(_imageData.title), - leading: SmoothBackButton( - iconColor: Colors.white, - onPressed: () => Navigator.maybePop(context), + floatingActionButton: FloatingActionButton.extended( + label: Text( + _isImageUrlAvailable + ? _appLocalizations.edit_photo_button_label + : _appLocalizations.add, + ), + icon: Icon( + _isImageUrlAvailable ? Icons.edit : Icons.add, + ), + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () { + final DaoInt daoInt = DaoInt(context.read()); + if (_isImageUrlAvailable) { + _editImage(daoInt); + } else { + _newImage(widget.imageData); + } + }, ), - ), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: BoxConstraints.tight( - Size(double.infinity, MediaQuery.of(context).size.height / 2), - ), - child: PhotoView( - minScale: 0.2, - imageProvider: - imageProvider, // TODO(monsieurtanuki): what if null? - heroAttributes: PhotoViewHeroAttributes( - tag: imageProvider ?? - Object(), // TODO(monsieurtanuki): what if null? - ), - backgroundDecoration: const BoxDecoration( - color: Colors.black, + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: BoxConstraints.tight( + Size(double.infinity, MediaQuery.of(context).size.height / 2), ), + child: _isImageUrlAvailable + ? PhotoView( + minScale: 0.2, + imageProvider: _imageProvider, + heroAttributes: PhotoViewHeroAttributes( + tag: _imageProvider!, + ), + backgroundDecoration: const BoxDecoration( + color: Colors.black, + ), + ) + : const PictureNotFound(), ), - ), - ], + ], + ), + ); + Future _newImage(ProductImageData data) async { + final File? croppedImageFile = await startNewImageCropping(this); + if (croppedImageFile == null) { + return; + } + if (!mounted) { + return; + } + final File? photoUploaded = await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => ConfirmAndUploadPicture( + barcode: widget.barcode, + imageField: data.imageField, + initialPhoto: croppedImageFile, + ), ), ); + if (photoUploaded != null) { + if (!mounted) { + return; + } + setState(() { + _imageProvider = FileImage(photoUploaded); + }); + } } - Future _editImage() async { - // we have no image at all here: we need to create one. - if (!TransientFile.isImageAvailable(_imageData, _barcode)) { - await confirmAndUploadNewPicture( - this, - imageField: _imageData.imageField, - barcode: _barcode, - ); + Future _editImage(final DaoInt daoInt) async { + final String? imageUrl = widget.imageData.getImageUrl(ImageSize.ORIGINAL); + if (imageUrl == null) { + await _showDownloadFailedDialog(_appLocalizations.image_edit_url_error); return; } - // best option: use the transient file. - File? imageFile = TransientFile.getImage( - _imageData.imageField, - _barcode, - ); + final File? imageFile = await LoadingDialog.run( + context: context, future: _downloadImageFile(daoInt, imageUrl)); - // but if not possible, get the best picture from the server. if (imageFile == null) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - final String? imageUrl = _imageData.getImageUrl(ImageSize.ORIGINAL); - if (imageUrl == null) { - await LoadingDialog.error( - context: context, - title: appLocalizations.image_edit_url_error, - ); - return; - } - - final DaoInt daoInt = DaoInt(context.read()); - imageFile = await LoadingDialog.run( - context: context, - future: _downloadImageFile(daoInt, imageUrl), - ); - - if (imageFile == null) { - await LoadingDialog.error( - context: context, - title: appLocalizations.image_download_error, - ); - return; - } + await _showDownloadFailedDialog(_appLocalizations.image_download_error); + return; } if (!mounted) { return; } - await Navigator.push( + final File? photoUploaded = await Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (BuildContext context) => ConfirmAndUploadPicture( - barcode: _barcode, - imageField: _imageData.imageField, - initialPhoto: imageFile!, + barcode: widget.barcode, + imageField: widget.imageData.imageField, + initialPhoto: imageFile, ), ), ); + if (photoUploaded != null) { + if (!mounted) { + return; + } + + setState(() { + _imageProvider = FileImage(photoUploaded); + }); + } } + Future _showDownloadFailedDialog(String? title) => + LoadingDialog.error(context: context, title: title); + static const String _CROP_IMAGE_SEQUENCE_KEY = 'crop_image_sequence'; - /// Downloads an image from the server and stores it locally in temp folder. Future _downloadImageFile(DaoInt daoInt, String url) async { final Uri uri = Uri.parse(url); final http.Response response = await http.get(uri);