diff --git a/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart b/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart index 3c3dd1bbdf2..ca0579a679b 100644 --- a/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart +++ b/packages/smooth_app/lib/cards/product_cards/product_image_carousel.dart @@ -22,8 +22,8 @@ class ProductImageCarousel extends StatelessWidget { final List productImagesData = getProductMainImagesData( product, ProductQuery.getLanguage(), - includeOther: true, ); + productImagesData.add(getEmptyProductImageData(ImageField.OTHER)); return SizedBox( height: height, child: ListView.builder( diff --git a/packages/smooth_app/lib/generic_lib/widgets/smooth_list_tile_card.dart b/packages/smooth_app/lib/generic_lib/widgets/smooth_list_tile_card.dart index c786a1cc978..68184d88247 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/smooth_list_tile_card.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/smooth_list_tile_card.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; -import 'package:smooth_app/generic_lib/widgets/images/smooth_image.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/themes/constant_icons.dart'; @@ -15,24 +13,6 @@ class SmoothListTileCard extends StatelessWidget { Key? key, }) : super(key: key); - /// Displays a [ListTile] inside a [SmoothCard] with a leading [Column] - /// containing the specified [imageProvider] - SmoothListTileCard.image({ - required ImageProvider? imageProvider, - Widget? title, - GestureTapCallback? onTap, - String? heroTag, - }) : this( - title: title, - onTap: onTap, - leading: SmoothImage( - width: VERY_LARGE_SPACE * 5, - height: MEDIUM_SPACE * 5, - imageProvider: imageProvider, - heroTag: heroTag, - ), - ); - /// Displays a [ListTile] inside a [SmoothCard] with a leading [Column] /// containing the specified [icon] SmoothListTileCard.icon({ @@ -53,36 +33,6 @@ class SmoothListTileCard extends StatelessWidget { ), ); - /// Displays a loading card with a shimmering effect - SmoothListTileCard.loading() - : this( - title: Shimmer.fromColors( - baseColor: GREY_COLOR, - highlightColor: WHITE_COLOR, - child: Row( - children: [ - Expanded( - child: Container( - decoration: const BoxDecoration( - color: GREY_COLOR, - borderRadius: CIRCULAR_BORDER_RADIUS, - ), - ), - ), - ], - ), - ), - leading: Shimmer.fromColors( - baseColor: GREY_COLOR, - highlightColor: WHITE_COLOR, - child: const SmoothImage( - width: VERY_LARGE_SPACE * 5, - height: MEDIUM_SPACE * 5, - color: GREY_COLOR, - ), - ), - ); - final Widget? title; final Widget? subtitle; final Widget? leading; diff --git a/packages/smooth_app/lib/generic_lib/widgets/smooth_product_image.dart b/packages/smooth_app/lib/generic_lib/widgets/smooth_product_image.dart index 0c95a9f63ce..64a65242052 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/smooth_product_image.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/smooth_product_image.dart @@ -22,11 +22,18 @@ class SmoothMainProductImage extends StatelessWidget { Widget build(BuildContext context) { context.watch(); final OpenFoodFactsLanguage language = ProductQuery.getLanguage(); - final ImageProvider? imageProvider = TransientFile.fromProduct( + ImageProvider? imageProvider = TransientFile.fromProduct( product, ImageField.FRONT, language, ).getImageProvider(); + // if we couldn't find an image for that specific language, use the default. + if (imageProvider == null) { + final String? url = product.imageFrontUrl; + if (url != null) { + imageProvider = NetworkImage(url); + } + } return SmoothImage( width: width, diff --git a/packages/smooth_app/lib/helpers/image_field_extension.dart b/packages/smooth_app/lib/helpers/image_field_extension.dart index 3c9af69cc5d..b294ff688fc 100644 --- a/packages/smooth_app/lib/helpers/image_field_extension.dart +++ b/packages/smooth_app/lib/helpers/image_field_extension.dart @@ -12,29 +12,6 @@ extension ImageFieldSmoothieExtension on ImageField { ImageField.PACKAGING, ]; - static const List orderedAll = [ - ImageField.FRONT, - ImageField.INGREDIENTS, - ImageField.NUTRITION, - ImageField.PACKAGING, - ImageField.OTHER, - ]; - - String? getUrl(final Product product) { - switch (this) { - case ImageField.FRONT: - return product.imageFrontUrl; - case ImageField.INGREDIENTS: - return product.imageIngredientsUrl; - case ImageField.NUTRITION: - return product.imageNutritionUrl; - case ImageField.PACKAGING: - return product.imagePackagingUrl; - case ImageField.OTHER: - return null; - } - } - void setUrl(final Product product, final String url) { switch (this) { case ImageField.FRONT: diff --git a/packages/smooth_app/lib/helpers/product_cards_helper.dart b/packages/smooth_app/lib/helpers/product_cards_helper.dart index 372744909e0..dac0f4841f1 100644 --- a/packages/smooth_app/lib/helpers/product_cards_helper.dart +++ b/packages/smooth_app/lib/helpers/product_cards_helper.dart @@ -213,59 +213,48 @@ Widget addPanelButton( List getProductMainImagesData( final Product product, - final OpenFoodFactsLanguage language, { - required final bool includeOther, -}) { - final List imageFields = List.of( - ImageFieldSmoothieExtension.orderedMain, - growable: true, - ); - if (includeOther) { - imageFields.add(ImageField.OTHER); - } + final OpenFoodFactsLanguage language, +) { final List result = []; - for (final ImageField element in imageFields) { - result.add(getProductImageData(product, element, language)); + for (final ImageField imageField in ImageFieldSmoothieExtension.orderedMain) { + result.add(getProductImageData(product, imageField, language)); } return result; } -/// Returns data about the "best" image: for the language, or the default. -/// -/// With [forceLanguage] you say you don't want the default as a fallback. +/// Returns data about the [imageField], for the [language]. ProductImageData getProductImageData( final Product product, final ImageField imageField, - final OpenFoodFactsLanguage language, { - final bool forceLanguage = false, -}) { + final OpenFoodFactsLanguage language, +) { final ProductImage? productImage = getLocalizedProductImage( product, imageField, language, ); - final String? imageUrl; - final OpenFoodFactsLanguage? imageLanguage; if (productImage != null) { // we found a localized version for this image - imageLanguage = language; - imageUrl = ImageHelper.getLocalizedProductImageUrl( - product.barcode!, - productImage, - imageSize: ImageSize.DISPLAY, + return ProductImageData( + imageField: imageField, + imageUrl: ImageHelper.getLocalizedProductImageUrl( + product.barcode!, + productImage, + imageSize: ImageSize.DISPLAY, + ), + language: language, ); - } else { - imageLanguage = null; - imageUrl = forceLanguage ? null : imageField.getUrl(product); } - - return ProductImageData( - imageField: imageField, - imageUrl: imageUrl, - language: imageLanguage, - ); + return getEmptyProductImageData(imageField); } +ProductImageData getEmptyProductImageData(final ImageField imageField) => + ProductImageData( + imageField: imageField, + imageUrl: null, + language: null, + ); + ProductImage? getLocalizedProductImage( final Product product, final ImageField imageField, @@ -285,24 +274,6 @@ ProductImage? getLocalizedProductImage( return null; } -List> getSelectedImages( - final Product product, - final OpenFoodFactsLanguage language, -) { - final Map result = - {}; - final List allProductImagesData = - getProductMainImagesData(product, language, includeOther: false); - for (final ProductImageData imageData in allProductImagesData) { - result[imageData] = TransientFile.fromProductImageData( - imageData, - product.barcode!, - language, - ).getImageProvider(); - } - return result.entries.toList(); -} - /// Returns the languages for which [imageField] has images for that [product]. Iterable getProductImageLanguages( final Product product, diff --git a/packages/smooth_app/lib/pages/product/add_new_product_helper.dart b/packages/smooth_app/lib/pages/product/add_new_product_helper.dart index 0af17e65901..a7c587491d6 100644 --- a/packages/smooth_app/lib/pages/product/add_new_product_helper.dart +++ b/packages/smooth_app/lib/pages/product/add_new_product_helper.dart @@ -180,8 +180,8 @@ class AddNewProductHelper { bool isOneMainImagePopulated(final Product product) { final List productImagesData = getProductMainImagesData( product, + // TODO(monsieurtanuki): check somehow with all languages ProductQuery.getLanguage(), - includeOther: false, ); for (final ProductImageData productImageData in productImagesData) { if (isMainImagePopulated(productImageData, product.barcode!)) { diff --git a/packages/smooth_app/lib/pages/product/add_new_product_page.dart b/packages/smooth_app/lib/pages/product/add_new_product_page.dart index 88abbd7bd47..4e98f23969c 100644 --- a/packages/smooth_app/lib/pages/product/add_new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/add_new_product_page.dart @@ -373,7 +373,6 @@ class _AddNewProductPageState extends State final List productImagesData = getProductMainImagesData( upToDateProduct, ProductQuery.getLanguage(), - includeOther: false, ); for (final ProductImageData data in productImagesData) { // Everything else can only be uploaded once 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 b62a21aaa59..ce3c3853bc0 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 @@ -2,10 +2,12 @@ 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/data_models/up_to_date_mixin.dart'; import 'package:smooth_app/database/local_database.dart'; -import 'package:smooth_app/generic_lib/widgets/smooth_list_tile_card.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_image.dart'; +import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/image_field_extension.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; @@ -31,24 +33,20 @@ class ProductImageGalleryView extends StatefulWidget { class _ProductImageGalleryViewState extends State with UpToDateMixin { - late List> _selectedImages; + late OpenFoodFactsLanguage _language; @override void initState() { super.initState(); initUpToDate(widget.product, context.read()); + _language = ProductQuery.getLanguage(); } @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); - final ThemeData theme = Theme.of(context); context.watch(); refreshUpToDate(); - _selectedImages = getSelectedImages( - upToDateProduct, - ProductQuery.getLanguage(), - ); return SmoothScaffold( appBar: SmoothAppBar( centerTitle: false, @@ -78,32 +76,66 @@ class _ProductImageGalleryViewState extends State barcode: barcode, widget: this, ), - child: ListView.builder( - itemCount: _selectedImages.length, - itemBuilder: (final BuildContext context, int index) { - final MapEntry item = - _selectedImages[index]; - - return SmoothListTileCard.image( - imageProvider: item.value, - title: Text( - item.key.imageField.getProductImageTitle(appLocalizations), - style: theme.textTheme.headlineMedium, - ), - onTap: () => _openImage( - imageData: item.key, - initialImageIndex: index, + child: SingleChildScrollView( + child: Column( + children: [ + LanguageSelector( + setLanguage: (final OpenFoodFactsLanguage? newLanguage) async { + if (newLanguage == null || newLanguage == _language) { + return; + } + setState(() => _language = newLanguage); + }, + displayedLanguage: _language, + selectedLanguages: null, + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 13.0, + vertical: SMALL_SPACE, + ), ), - heroTag: 'photo_${item.key.imageField.offTag}', - ); - }, + _ImageRow(row: 1, product: upToDateProduct, language: _language), + _TextRow(row: 1, product: upToDateProduct, language: _language), + _ImageRow(row: 2, product: upToDateProduct, language: _language), + _TextRow(row: 2, product: upToDateProduct, language: _language), + // TODO(monsieurtanuki): add "other photos" for issue 4674 + ], + ), ), ), ); } +} + +abstract class _GenericRow extends StatelessWidget { + const _GenericRow({ + required this.row, + required this.product, + required this.language, + }); + + /// Displayed row, starting from 1. + final int row; + final Product product; + final OpenFoodFactsLanguage language; + + @protected + int get index1 => (row - 1) * 2; + + @protected + int get index2 => index1 + 1; + + @protected + ImageField getImageField(final int index) => + ImageFieldSmoothieExtension.orderedMain[index]; + + static const double _innerPadding = SMALL_SPACE; + + @protected + double getSquareSize(final BuildContext context) => + (MediaQuery.of(context).size.width - _innerPadding) / 2; Future _openImage({ - required ProductImageData imageData, + required BuildContext context, required int initialImageIndex, }) async => Navigator.push( @@ -111,8 +143,135 @@ class _ProductImageGalleryViewState extends State MaterialPageRoute( builder: (_) => ProductImageSwipeableView( initialImageIndex: initialImageIndex, - product: upToDateProduct, + product: product, isLoggedInMandatory: true, + initialLanguage: language, + ), + ), + ); +} + +class _ImageRow extends _GenericRow { + const _ImageRow({ + required super.row, + required super.product, + required super.language, + }); + + TransientFile _getTransientFile(final ImageField imageField) => + TransientFile.fromProductImageData( + getProductImageData(product, imageField, language), + product.barcode!, + language, + ); + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Image( + squareSize: getSquareSize(context), + imageProvider: + _getTransientFile(getImageField(index1)).getImageProvider(), + onTap: () => _openImage( + context: context, + initialImageIndex: index1, + ), + ), + _Image( + squareSize: getSquareSize(context), + imageProvider: + _getTransientFile(getImageField(index2)).getImageProvider(), + onTap: () => _openImage( + context: context, + initialImageIndex: index2, + ), + ), + ], + ); +} + +class _TextRow extends _GenericRow { + const _TextRow({ + required super.row, + required super.product, + required super.language, + }); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only( + top: SMALL_SPACE, + bottom: LARGE_SPACE, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Text( + squareSize: getSquareSize(context), + imageField: getImageField(index1), + onTap: () => _openImage( + context: context, + initialImageIndex: index1, + ), + ), + _Text( + squareSize: getSquareSize(context), + imageField: getImageField(index2), + onTap: () => _openImage( + context: context, + initialImageIndex: index2, + ), + ), + ], + ), + ); +} + +class _Image extends StatelessWidget { + const _Image({ + required this.squareSize, + required this.imageProvider, + required this.onTap, + }); + + final double squareSize; + final ImageProvider? imageProvider; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: SmoothImage( + width: squareSize, + height: squareSize, + imageProvider: imageProvider, + ), + ); +} + +class _Text extends StatelessWidget { + const _Text({ + required this.squareSize, + required this.imageField, + required this.onTap, + }); + + final double squareSize; + final ImageField imageField; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + child: SizedBox( + width: squareSize, + child: Center( + child: Text( + imageField.getProductImageTitle(AppLocalizations.of(context)), + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), ), ), ); 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 index 8208b2959f3..f2f5463a720 100644 --- a/packages/smooth_app/lib/pages/product/product_image_swipeable_view.dart +++ b/packages/smooth_app/lib/pages/product/product_image_swipeable_view.dart @@ -3,13 +3,11 @@ import 'package:flutter/services.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/data_models/up_to_date_mixin.dart'; import 'package:smooth_app/database/local_database.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/helpers/image_field_extension.dart'; -import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/pages/product/product_image_viewer.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/widgets/smooth_app_bar.dart'; @@ -23,6 +21,7 @@ class ProductImageSwipeableView extends StatefulWidget { required this.product, required this.initialImageIndex, required this.isLoggedInMandatory, + this.initialLanguage, }) : imageField = null; /// Version with only one main [ImageField]. @@ -31,12 +30,14 @@ class ProductImageSwipeableView extends StatefulWidget { required this.product, required this.imageField, required this.isLoggedInMandatory, + this.initialLanguage, }) : initialImageIndex = 0; final Product product; final int initialImageIndex; final ImageField? imageField; final bool isLoggedInMandatory; + final OpenFoodFactsLanguage? initialLanguage; @override State createState() => @@ -48,7 +49,7 @@ class _ProductImageSwipeableViewState extends State //Making use of [ValueNotifier] such that to avoid performance issues //while swiping between pages by making sure only [Text] widget for product title is rebuilt late final ValueNotifier _currentImageDataIndex; - late List> _selectedImages; + late List _imageFields; late PageController _controller; late OpenFoodFactsLanguage _currentLanguage; @@ -60,7 +61,12 @@ class _ProductImageSwipeableViewState extends State initialPage: widget.initialImageIndex, ); _currentImageDataIndex = ValueNotifier(widget.initialImageIndex); - _currentLanguage = ProductQuery.getLanguage(); + _currentLanguage = widget.initialLanguage ?? ProductQuery.getLanguage(); + if (widget.imageField != null) { + _imageFields = [widget.imageField!]; + } else { + _imageFields = ImageFieldSmoothieExtension.orderedMain; + } } @override @@ -68,15 +74,6 @@ class _ProductImageSwipeableViewState extends State final AppLocalizations appLocalizations = AppLocalizations.of(context); context.watch(); refreshUpToDate(); - _selectedImages = getSelectedImages(upToDateProduct, _currentLanguage); - if (widget.imageField != null) { - _selectedImages.removeWhere( - ( - final MapEntry?> element, - ) => - element.key.imageField != widget.imageField, - ); - } return SmoothScaffold( backgroundColor: Colors.black, appBar: SmoothAppBar( @@ -88,9 +85,7 @@ class _ProductImageSwipeableViewState extends State title: ValueListenableBuilder( valueListenable: _currentImageDataIndex, builder: (_, int index, __) => Text( - _selectedImages[index].key.imageField.getImagePageTitle( - appLocalizations, - ), + _imageFields[index].getImagePageTitle(appLocalizations), maxLines: 2, ), ), @@ -102,10 +97,10 @@ class _ProductImageSwipeableViewState extends State body: PageView.builder( onPageChanged: (int index) => _currentImageDataIndex.value = index, controller: _controller, - itemCount: _selectedImages.length, + itemCount: _imageFields.length, itemBuilder: (BuildContext context, int index) => ProductImageViewer( product: widget.product, - imageField: _selectedImages[index].key.imageField, + imageField: _imageFields[index], language: _currentLanguage, setLanguage: (final OpenFoodFactsLanguage? newLanguage) async { if (newLanguage == null || newLanguage == _currentLanguage) { 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 aa74da348ea..34cc36d134e 100644 --- a/packages/smooth_app/lib/pages/product/product_image_viewer.dart +++ b/packages/smooth_app/lib/pages/product/product_image_viewer.dart @@ -65,7 +65,6 @@ class _ProductImageViewerState extends State upToDateProduct, widget.imageField, widget.language, - forceLanguage: true, ); final ImageProvider? imageProvider = _getTransientFile().getImageProvider(); final Iterable selectedLanguages =