From e53b0f3c09f7db8e7e3e5b0a7d038675e9159e01 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Sun, 1 Dec 2024 11:09:19 +0100 Subject: [PATCH] feat: An indicator if the photo may be locked by the producer (#5974) * An indicator if the photo may be locked by the producer * Implement suggestions --- .../product_cards/smooth_product_image.dart | 249 ++++++++++++---- .../lib/helpers/image_field_extension.dart | 16 + packages/smooth_app/lib/l10n/app_en.arb | 63 +++- .../product_image_gallery_details_banner.dart | 282 ++++++++++++++++++ .../product_image_gallery_photo_row.dart | 168 ++--------- .../lib/pages/product/owner_field_info.dart | 5 +- .../product/product_image_swipeable_view.dart | 53 ++++ 7 files changed, 620 insertions(+), 216 deletions(-) create mode 100644 packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_details_banner.dart diff --git a/packages/smooth_app/lib/cards/product_cards/smooth_product_image.dart b/packages/smooth_app/lib/cards/product_cards/smooth_product_image.dart index 01929bf735f..a05c2685e92 100644 --- a/packages/smooth_app/lib/cards/product_cards/smooth_product_image.dart +++ b/packages/smooth_app/lib/cards/product_cards/smooth_product_image.dart @@ -9,7 +9,9 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/database/transient_file.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/helpers/image_field_extension.dart'; import 'package:smooth_app/pages/image/product_image_helper.dart'; +import 'package:smooth_app/pages/product/owner_field_info.dart'; import 'package:smooth_app/pages/product/product_page/new_product_page.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/resources/app_icons.dart' as icons; @@ -26,10 +28,11 @@ class ProductPicture extends StatefulWidget { String? fallbackUrl, VoidCallback? onTap, String? heroTag, - bool? showObsoleteIcon, + bool showObsoleteIcon = false, + bool showOwnerIcon = false, BorderRadius? borderRadius, - double? imageFoundBorder, - double? imageNotFoundBorder, + double imageFoundBorder = 0.0, + double imageNotFoundBorder = 0.0, TextStyle? errorTextStyle, }) : this._( transientFile: null, @@ -41,37 +44,43 @@ class ProductPicture extends StatefulWidget { heroTag: heroTag, onTap: onTap, borderRadius: borderRadius, - imageFoundBorder: imageFoundBorder ?? 0.0, - imageNotFoundBorder: imageNotFoundBorder ?? 0.0, + imageFoundBorder: imageFoundBorder, + imageNotFoundBorder: imageNotFoundBorder, errorTextStyle: errorTextStyle, - showObsoleteIcon: showObsoleteIcon ?? false, + showObsoleteIcon: showObsoleteIcon, + showOwnerIcon: showOwnerIcon, ); ProductPicture.fromTransientFile({ required TransientFile transientFile, required Size size, + OpenFoodFactsLanguage? language, + Product? product, + ImageField? imageField, String? fallbackUrl, VoidCallback? onTap, String? heroTag, - bool? showObsoleteIcon, + bool showObsoleteIcon = false, + bool showOwnerIcon = false, BorderRadius? borderRadius, - double? imageFoundBorder, - double? imageNotFoundBorder, + double imageFoundBorder = 0.0, + double imageNotFoundBorder = 0.0, TextStyle? errorTextStyle, }) : this._( transientFile: transientFile, - product: null, - imageField: null, - language: null, + product: product, + imageField: imageField, + language: language, size: size, fallbackUrl: fallbackUrl, heroTag: heroTag, onTap: onTap, borderRadius: borderRadius, - imageFoundBorder: imageFoundBorder ?? 0.0, - imageNotFoundBorder: imageNotFoundBorder ?? 0.0, + imageFoundBorder: imageFoundBorder, + imageNotFoundBorder: imageNotFoundBorder, errorTextStyle: errorTextStyle, - showObsoleteIcon: showObsoleteIcon ?? false, + showObsoleteIcon: showObsoleteIcon, + showOwnerIcon: showOwnerIcon, ); ProductPicture._({ @@ -88,6 +97,7 @@ class ProductPicture extends StatefulWidget { this.imageNotFoundBorder = 0.0, this.errorTextStyle, this.showObsoleteIcon = false, + this.showOwnerIcon = false, super.key, }) : assert(imageFoundBorder >= 0.0), assert(imageNotFoundBorder >= 0.0), @@ -108,6 +118,9 @@ class ProductPicture extends StatefulWidget { /// Show the obsolete icon on top of the image final bool showObsoleteIcon; + /// Show the owner icon on top of the image + final bool showOwnerIcon; + /// Rounded borders around the image final BorderRadius? borderRadius; final double imageFoundBorder; @@ -147,8 +160,11 @@ class _ProductPictureState extends State { child = _ProductPictureAssetsSvg( asset: 'assets/product/product_error.svg', semanticsLabel: - appLocalizations.product_page_image_error_accessibility_label, - text: appLocalizations.product_page_image_error, + appLocalizations.product_image_error_accessibility_label( + widget.imageField?.getPictureAccessibilityLabel(appLocalizations) ?? + appLocalizations.product_image_front_accessibility_label, + ), + text: appLocalizations.product_image_error, textStyle: TextStyle( color: context.extension().red, ).merge(widget.errorTextStyle ?? const TextStyle()), @@ -160,10 +176,18 @@ class _ProductPictureState extends State { } else if (imageProvider?.$1 != null) { child = _ProductPictureWithImageProvider( imageProvider: imageProvider!.$1!, + imageField: widget.imageField, outdated: imageProvider.$2, + locked: widget.imageField != null && + widget.product?.isImageLocked( + widget.imageField!, + widget.language ?? ProductQuery.getLanguage(), + ) == + true, heroTag: widget.heroTag, size: widget.size, showOutdated: widget.showObsoleteIcon, + showOwner: widget.showOwnerIcon, borderRadius: widget.borderRadius, border: widget.imageFoundBorder, onError: () { @@ -243,21 +267,27 @@ class _ProductPictureWithImageProvider extends StatelessWidget { const _ProductPictureWithImageProvider({ required this.imageProvider, required this.outdated, + required this.locked, required this.size, required this.child, required this.onError, required this.showOutdated, + required this.showOwner, required this.border, + this.imageField, this.borderRadius, this.heroTag, }); final ImageProvider imageProvider; + final ImageField? imageField; final bool outdated; + final bool locked; final Size size; final Widget? child; final VoidCallback onError; final bool showOutdated; + final bool showOwner; final BorderRadius? borderRadius; final double border; final String? heroTag; @@ -268,7 +298,8 @@ class _ProductPictureWithImageProvider extends StatelessWidget { final bool lightTheme = context.lightTheme(); final Widget image = Semantics( - label: appLocalizations.product_page_image_front_accessibility_label, + label: imageField?.getPictureAccessibilityLabel(appLocalizations) ?? + appLocalizations.product_image_front_accessibility_label, image: true, excludeSemantics: true, child: SizedBox.fromSize( @@ -318,43 +349,54 @@ class _ProductPictureWithImageProvider extends StatelessWidget { ), ); - if (showOutdated && outdated) { - return Semantics( - label: appLocalizations - .product_page_image_front_outdated_message_accessibility_label, - image: true, - excludeSemantics: true, - child: Tooltip( - message: appLocalizations.product_page_image_front_outdated_message, - child: Stack( - children: [ - image, - Positioned.directional( - bottom: 2.0, - end: 2.0, - textDirection: Directionality.of(context), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.white54, - borderRadius: borderRadius, - ), - child: const Padding( - padding: EdgeInsetsDirectional.only( - top: 4.5, - bottom: 5.5, - start: 5.0, - end: 5.0, - ), - child: icons.Outdated( - size: 15.0, - color: Color(0xFF616161), - ), - ), - ), + final Widget? iconOutdated = showOutdated && outdated + ? _OutdatedProductPictureIcon( + appLocalizations: appLocalizations, + borderRadius: borderRadius, + imageField: imageField, + ) + : null; + + final Widget? iconLocked = showOwner && locked + ? _LockedProductPictureIcon( + appLocalizations: appLocalizations, + borderRadius: borderRadius, + imageField: imageField, + ) + : null; + + Widget? icons; + if (iconOutdated == null) { + icons = iconLocked; + } else if (iconLocked == null) { + icons = iconOutdated; + } else { + icons = Column( + mainAxisSize: MainAxisSize.min, + children: [ + iconOutdated, + const SizedBox(height: SMALL_SPACE), + iconLocked, + ], + ); + } + + if (icons != null) { + return Stack( + children: [ + image, + Positioned.directional( + bottom: 2.0, + end: 2.0, + textDirection: Directionality.of(context), + child: IconTheme( + data: const IconThemeData( + color: Color(0xFF616161), ), - ], + child: icons, + ), ), - ), + ], ); } @@ -393,6 +435,107 @@ class _ProductPictureWithImageProvider extends StatelessWidget { } } +class _OutdatedProductPictureIcon extends StatelessWidget { + const _OutdatedProductPictureIcon({ + required this.appLocalizations, + required this.borderRadius, + this.imageField, + }); + + final ImageField? imageField; + final AppLocalizations appLocalizations; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return _ProductPictureIcon( + semanticsLabel: + appLocalizations.product_image_outdated_message_accessibility_label( + imageField?.getPictureAccessibilityLabel(appLocalizations) ?? + appLocalizations.product_image_front_accessibility_label, + ), + icon: const icons.Outdated(size: 15.0), + padding: const EdgeInsetsDirectional.only( + top: 4.5, + bottom: 5.5, + start: 5.0, + end: 5.0, + ), + borderRadius: borderRadius, + ); + } +} + +class _LockedProductPictureIcon extends StatelessWidget { + const _LockedProductPictureIcon({ + required this.appLocalizations, + required this.borderRadius, + this.imageField, + }); + + final ImageField? imageField; + final AppLocalizations appLocalizations; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return _ProductPictureIcon( + semanticsLabel: + appLocalizations.product_image_locked_message_accessibility_label( + imageField?.getPictureAccessibilityLabel(appLocalizations) ?? + appLocalizations.product_image_front_accessibility_label, + ), + icon: IconTheme.merge( + data: const IconThemeData(size: 16.0), + child: const OwnerFieldIcon(), + ), + padding: const EdgeInsetsDirectional.only( + top: 4.5, + bottom: 5.5, + start: 5.0, + end: 5.0, + ), + borderRadius: borderRadius, + ); + } +} + +class _ProductPictureIcon extends StatelessWidget { + const _ProductPictureIcon({ + required this.semanticsLabel, + required this.icon, + required this.padding, + this.borderRadius, + }); + + final String semanticsLabel; + final Widget icon; + final EdgeInsetsGeometry padding; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return Semantics( + label: semanticsLabel, + image: true, + excludeSemantics: true, + child: Tooltip( + message: semanticsLabel, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white54, + borderRadius: borderRadius, + ), + child: Padding( + padding: padding, + child: icon, + ), + ), + ), + ); + } +} + class _ProductPictureAssetsSvg extends StatelessWidget { _ProductPictureAssetsSvg({ required this.asset, diff --git a/packages/smooth_app/lib/helpers/image_field_extension.dart b/packages/smooth_app/lib/helpers/image_field_extension.dart index 369933f0fd1..47132f4efd9 100644 --- a/packages/smooth_app/lib/helpers/image_field_extension.dart +++ b/packages/smooth_app/lib/helpers/image_field_extension.dart @@ -85,6 +85,22 @@ extension ImageFieldSmoothieExtension on ImageField { ImageField.OTHER => appLocalizations.take_more_photo_button_label, }; + String getPictureAccessibilityLabel( + final AppLocalizations appLocalizations, + ) => + switch (this) { + ImageField.FRONT => + appLocalizations.product_image_front_accessibility_label, + ImageField.INGREDIENTS => + appLocalizations.product_image_ingredients_accessibility_label, + ImageField.NUTRITION => + appLocalizations.product_image_nutrition_accessibility_label, + ImageField.PACKAGING => + appLocalizations.product_image_packaging_accessibility_label, + ImageField.OTHER => + appLocalizations.product_image_other_accessibility_label, + }; + Widget getPhotoButton( final BuildContext context, final Product product, diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 8a5574ff3de..53ba65df6ab 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -2681,6 +2681,10 @@ "@owner_field_info_close_button": { "description": "The owner info may be shown in a closeable dialog. This is the label of the button (used on a long press event and for the accessibility label)." }, + "owner_field_image": "This image is provided by the producer. It may not be editable.", + "@owner_field_image": { + "description": "An image is directly provided by the producer. It may be locked and not be editable." + }, "edit_packagings_title": "Packaging components", "@edit_packagings_title": { "description": "Title of the structured packagings page" @@ -3225,25 +3229,60 @@ } } }, - "product_page_image_front_accessibility_label": "Front picture", - "@product_page_image_front_accessibility_label": { + "product_image_front_accessibility_label": "Front picture", + "@product_image_front_accessibility_label": { "description": "Accessibility label for the image on the product page" }, - "product_page_image_front_outdated_message": "This picture may be outdated", - "@product_page_image_front_outdated_message": { + "product_image_ingredients_accessibility_label": "Ingredients picture", + "@product_image_ingredients_accessibility_label": { + "description": "Accessibility label for the image of ingredients" + }, + "product_image_nutrition_accessibility_label": "Nutrition picture", + "@product_image_nutrition_accessibility_label": { + "description": "Accessibility label for the image of the nutrition" + }, + "product_image_packaging_accessibility_label": "Packaging picture", + "@product_image_packaging_accessibility_label": { + "description": "Accessibility label for the image of the packaging" + }, + "product_image_other_accessibility_label": "Other picture", + "@product_image_other_accessibility_label": { + "description": "Accessibility label for an image" + }, + "product_image_outdated_message": "This picture may be outdated", + "@product_image_outdated_message": { "description": "Small message to indicate that the image may be outdated" }, - "product_page_image_front_outdated_message_accessibility_label": "Front picture (this image may be outdated)", - "@product_page_image_front_outdated_message_accessibility_label": { - "description": "Accessibility label for the image on the product page when it may be outdated" + "product_image_outdated_message_accessibility_label": "{type} (this image may be outdated)", + "@product_image_outdated_message_accessibility_label": { + "description": "Accessibility label for the image on the product page when it may be outdated", + "placeholders": { + "type": { + "type": "String" + } + } }, - "product_page_image_error": "Unable to load the image!", - "@product_page_image_error": { + "product_image_locked_message_accessibility_label": "{type} (this image may be locked by the producer)", + "@product_image_locked_message_accessibility_label": { + "description": "Accessibility label for the image on the product page when it may be locked (producer provided)", + "placeholders": { + "type": { + "type": "String" + } + } + }, + "product_image_error": "Unable to load the image!", + "@product_image_error": { "description": "Small message that will be displayed above the picture (please keep it short)" }, - "product_page_image_error_accessibility_label": "Unable to load the front picture (network error?)", - "@product_page_image_error_accessibility_label": { - "description": "Accessibility label for the image on the product page when it fails to load" + "product_image_error_accessibility_label": "Unable to load the {type} (network error?)", + "@product_image_error_accessibility_label": { + "description": "Accessibility label for the image on the product page when it fails to load", + "placeholders": { + "type": { + "type": "String" + } + } }, "product_page_image_no_image_available": "No\nimage!", "@product_page_image_no_image_available": { diff --git a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_details_banner.dart b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_details_banner.dart new file mode 100644 index 00000000000..28501b5e3d6 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_details_banner.dart @@ -0,0 +1,282 @@ +part of 'package:smooth_app/pages/product/gallery_view/product_image_gallery_photo_row.dart'; + +Future<_PhotoRowActions?> _showPhotoBanner({ + required final BuildContext context, + required final Product product, + required final ImageField imageField, + required final OpenFoodFactsLanguage language, + required final TransientFile transientFile, +}) async { + final SmoothColorsThemeExtension extension = + context.extension(); + final bool lightTheme = context.lightTheme(listen: false); + final bool imageAvailable = transientFile.isImageAvailable(); + + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + final _PhotoRowActions? action = + await showSmoothListOfChoicesModalSheet<_PhotoRowActions>( + context: context, + title: imageAvailable + ? appLocalizations.product_image_action_replace_photo( + imageField.getProductImageTitle(appLocalizations)) + : appLocalizations.product_image_action_add_photo( + imageField.getProductImageTitle(appLocalizations)), + values: _PhotoRowActions.values, + labels: [ + if (imageAvailable) + appLocalizations.product_image_action_take_new_picture + else + appLocalizations.product_image_action_take_picture, + appLocalizations.product_image_action_from_gallery, + appLocalizations.product_image_action_choose_existing_photo, + ], + prefixIconTint: + lightTheme ? extension.primaryDark : extension.primaryMedium, + prefixIcons: [ + const Icon(Icons.camera), + const Icon(Icons.perm_media_rounded), + const Icon(Icons.image_search_rounded), + ], + addEndArrowToItems: true, + footer: _PhotoRowBanner( + children: [ + _PhotoRowDate(transientFile: transientFile), + _PhotoRowLockedStatus( + product: product, + imageField: imageField, + language: language, + ), + ], + ), + ); + + return action; +} + +class _PhotoRowBanner extends StatelessWidget { + const _PhotoRowBanner({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsetsDirectional.only( + top: MEDIUM_SPACE, + bottom: !(Platform.isIOS || Platform.isMacOS) ? 0.0 : VERY_SMALL_SPACE, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } +} + +enum _PhotoRowActions { + takePicture, + selectFromGallery, + selectFromProductPhotos, +} + +/// The date of the photo (used in the modal sheet) +class _PhotoRowDate extends StatelessWidget { + const _PhotoRowDate({ + required this.transientFile, + }); + + final TransientFile transientFile; + + @override + Widget build(BuildContext context) { + if (!transientFile.isImageAvailable()) { + return EMPTY_WIDGET; + } + + final SmoothColorsThemeExtension extension = + context.extension(); + final bool outdated = transientFile.expired; + + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return _PhotoRowInfo( + icon: outdated ? _outdatedIcon : _successIcon, + iconBackgroundColor: outdated ? extension.warning : extension.success, + text: Padding( + /// Padding required by the use of [RichText] + padding: const EdgeInsetsDirectional.only(bottom: 2.75), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${appLocalizations.date}${appLocalizations.sep}: ', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: DateFormat.yMd(ProductQuery.getLocaleString()) + .format(transientFile.uploadedDate!), + ), + ], + style: DefaultTextStyle.of(context).style.merge( + const TextStyle( + fontSize: 15.0, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ), + ), + ); + } + + Widget get _outdatedIcon => const Padding( + padding: EdgeInsetsDirectional.only( + bottom: 1.5, + start: 1.5, + ), + child: icons.Outdated( + color: Colors.white, + size: 19.0, + ), + ); + + Widget get _successIcon => const Padding( + padding: EdgeInsetsDirectional.only( + bottom: 0.5, + start: 0.5, + ), + child: icons.Clock( + color: Colors.white, + size: 19.0, + ), + ); +} + +/// If the photo is locked by the owner (used in the modal sheet) +class _PhotoRowLockedStatus extends StatelessWidget { + const _PhotoRowLockedStatus({ + required this.product, + required this.imageField, + required this.language, + }); + + final Product product; + final ImageField imageField; + final OpenFoodFactsLanguage language; + + @override + Widget build(BuildContext context) { + if (product.isImageLocked(imageField, language) != true) { + return EMPTY_WIDGET; + } + + final SmoothColorsThemeExtension extension = + context.extension(); + + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return Padding( + padding: const EdgeInsetsDirectional.only(top: SMALL_SPACE), + child: _PhotoRowInfo( + icon: const IconTheme( + data: IconThemeData( + size: 19.0, + color: Colors.white, + ), + child: Padding( + padding: EdgeInsetsDirectional.only(bottom: 2.0), + child: OwnerFieldIcon(), + ), + ), + iconBackgroundColor: extension.warning, + text: Text( + appLocalizations.owner_field_image, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + wrapTextInExpanded: true, + ), + ); + } +} + +/// Show an info in the modal sheet +class _PhotoRowInfo extends StatelessWidget { + const _PhotoRowInfo({ + required this.icon, + required this.iconBackgroundColor, + required this.text, + this.wrapTextInExpanded = false, + }); + + final Widget icon; + final Color iconBackgroundColor; + final Widget text; + final bool wrapTextInExpanded; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension extension = + context.extension(); + + return Padding( + padding: const EdgeInsetsDirectional.symmetric(horizontal: SMALL_SPACE), + child: DecoratedBox( + decoration: BoxDecoration( + color: extension.primaryDark, + borderRadius: BorderRadius.all( + Radius.circular(MediaQuery.of(context).size.height), + ), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: 47.5, + maxWidth: MediaQuery.sizeOf(context).width * 0.95, + ), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: 47.5, + child: DecoratedBox( + decoration: BoxDecoration( + color: iconBackgroundColor, + shape: BoxShape.circle, + ), + child: Center(child: icon), + ), + ), + _textWidget, + ], + ), + ), + ), + ), + ); + } + + Widget get _textWidget { + final Widget textWidget = Padding( + padding: const EdgeInsetsDirectional.only( + start: MEDIUM_SPACE, + end: VERY_LARGE_SPACE, + ), + child: text, + ); + + if (wrapTextInExpanded) { + return Expanded( + child: textWidget, + ); + } + + return textWidget; + } +} diff --git a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_photo_row.dart b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_photo_row.dart index 6ef0f9291fb..a75b2d0ae02 100644 --- a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_photo_row.dart +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_photo_row.dart @@ -15,6 +15,7 @@ import 'package:smooth_app/helpers/image_field_extension.dart'; import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/image/product_image_helper.dart'; import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_view.dart'; +import 'package:smooth_app/pages/product/owner_field_info.dart'; import 'package:smooth_app/pages/product/product_image_server_button.dart'; import 'package:smooth_app/pages/product/product_image_swipeable_view.dart'; import 'package:smooth_app/query/product_query.dart'; @@ -24,6 +25,8 @@ import 'package:smooth_app/themes/smooth_theme.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/themes/theme_provider.dart'; +part 'product_image_gallery_details_banner.dart'; + class ImageGalleryPhotoRow extends StatefulWidget { const ImageGalleryPhotoRow({ required this.position, @@ -101,6 +104,7 @@ class _ImageGalleryPhotoRowState extends State { context: context, product: product, transientFile: transientFile, + language: widget.language, ), child: ClipRRect( borderRadius: ANGULAR_BORDER_RADIUS, @@ -160,6 +164,9 @@ class _ImageGalleryPhotoRowState extends State { } return ProductPicture.fromTransientFile( + product: product, + imageField: widget.imageField, + language: widget.language, transientFile: transientFile, size: Size(box.maxWidth, box.maxHeight), onTap: null, @@ -171,6 +178,8 @@ class _ImageGalleryPhotoRowState extends State { product.barcode!, widget.imageField, ), + showObsoleteIcon: false, + showOwnerIcon: true, ); }, ), @@ -222,41 +231,15 @@ class _ImageGalleryPhotoRowState extends State { Future _onLongTap({ required final BuildContext context, required final Product product, + required final OpenFoodFactsLanguage language, required final TransientFile transientFile, }) async { - final SmoothColorsThemeExtension extension = - context.extension(); - final bool lightTheme = context.lightTheme(listen: false); - final bool imageAvailable = transientFile.isImageAvailable(); - - final AppLocalizations appLocalizations = AppLocalizations.of(context); - - final _PhotoRowActions? action = - await showSmoothListOfChoicesModalSheet<_PhotoRowActions>( + final _PhotoRowActions? action = await _showPhotoBanner( context: context, - title: imageAvailable - ? appLocalizations.product_image_action_replace_photo( - widget.imageField.getProductImageTitle(appLocalizations)) - : appLocalizations.product_image_action_add_photo( - widget.imageField.getProductImageTitle(appLocalizations)), - values: _PhotoRowActions.values, - labels: [ - if (imageAvailable) - appLocalizations.product_image_action_take_new_picture - else - appLocalizations.product_image_action_take_picture, - appLocalizations.product_image_action_from_gallery, - appLocalizations.product_image_action_choose_existing_photo, - ], - prefixIconTint: - lightTheme ? extension.primaryDark : extension.primaryMedium, - prefixIcons: [ - const Icon(Icons.camera), - const Icon(Icons.perm_media_rounded), - const Icon(Icons.image_search_rounded), - ], - addEndArrowToItems: true, - footer: _PhotoRowDate(transientFile: transientFile), + product: product, + imageField: widget.imageField, + language: language, + transientFile: transientFile, ); if (!context.mounted || action == null) { @@ -348,123 +331,6 @@ class _ImageGalleryPhotoRowState extends State { ); } -enum _PhotoRowActions { - takePicture, - selectFromGallery, - selectFromProductPhotos, -} - -/// The date of the photo (used in the modal sheet) -class _PhotoRowDate extends StatelessWidget { - const _PhotoRowDate({ - required this.transientFile, - }); - - final TransientFile transientFile; - - @override - Widget build(BuildContext context) { - if (!transientFile.isImageAvailable()) { - return EMPTY_WIDGET; - } - - final SmoothColorsThemeExtension extension = - context.extension(); - final bool outdated = transientFile.expired; - - final AppLocalizations appLocalizations = AppLocalizations.of(context); - - return Padding( - padding: EdgeInsetsDirectional.only( - top: MEDIUM_SPACE, - bottom: !(Platform.isIOS || Platform.isMacOS) ? 0.0 : VERY_SMALL_SPACE, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: extension.primaryDark, - borderRadius: BorderRadius.all( - Radius.circular(MediaQuery.of(context).size.height), - ), - ), - child: IntrinsicHeight( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AspectRatio( - aspectRatio: 1.0, - child: DecoratedBox( - decoration: BoxDecoration( - color: outdated ? extension.warning : extension.success, - shape: BoxShape.circle, - ), - child: outdated ? _outdatedIcon : _successIcon, - ), - ), - Padding( - padding: const EdgeInsetsDirectional.only( - start: MEDIUM_SPACE, - end: VERY_LARGE_SPACE, - bottom: 2.75, - ), - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: - '${appLocalizations.date}${appLocalizations.sep}: ', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: DateFormat.yMd(ProductQuery.getLocaleString()) - .format(transientFile.uploadedDate!), - ), - ], - style: DefaultTextStyle.of(context).style.merge( - const TextStyle( - fontSize: 15.0, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - - Widget get _outdatedIcon => const Padding( - padding: EdgeInsetsDirectional.only( - top: 12.0, - bottom: 16.5, - start: 12.5, - end: 12.0, - ), - child: icons.Outdated( - color: Colors.white, - size: 19.0, - ), - ); - - Widget get _successIcon => const Padding( - padding: EdgeInsetsDirectional.only( - top: 12.0, - bottom: 16.5, - start: 12.0, - end: 12.0, - ), - child: icons.Clock( - color: Colors.white, - size: 19.0, - ), - ); -} - class _PhotoRowIndicator extends StatelessWidget { const _PhotoRowIndicator({ required this.transientFile, @@ -486,7 +352,9 @@ class _PhotoRowIndicator extends StatelessWidget { context.extension(), ), ), - child: Center(child: child()), + child: Center( + child: child(), + ), ), ); } diff --git a/packages/smooth_app/lib/pages/product/owner_field_info.dart b/packages/smooth_app/lib/pages/product/owner_field_info.dart index 995505738c8..ac9c9076f4e 100644 --- a/packages/smooth_app/lib/pages/product/owner_field_info.dart +++ b/packages/smooth_app/lib/pages/product/owner_field_info.dart @@ -54,11 +54,14 @@ class OwnerFieldBanner extends StatelessWidget { /// Standard icon about "owner fields". class OwnerFieldIcon extends StatelessWidget { - const OwnerFieldIcon(); + const OwnerFieldIcon({this.size, super.key}); + + final double? size; @override Widget build(BuildContext context) => Icon( _ownerFieldIconData, + size: size, semanticLabel: AppLocalizations.of(context).owner_field_info_title, ); } 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 e652340201f..173d73a0f71 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 @@ -5,9 +5,11 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.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/bottom_sheets/smooth_bottom_sheet.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/pages/product/owner_field_info.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'; @@ -96,6 +98,13 @@ class _ProductImageSwipeableViewState extends State iconColor: Colors.white, onPressed: () => Navigator.maybePop(context), ), + actions: [ + ValueListenableBuilder( + valueListenable: _currentImageDataIndex, + builder: (_, int index, __) { + return _lockedIcon(_imageFields[index]); + }) + ], ), body: PageView.builder( onPageChanged: (int index) => _currentImageDataIndex.value = index, @@ -117,4 +126,48 @@ class _ProductImageSwipeableViewState extends State ), ); } + + Widget _lockedIcon(ImageField imageField) { + if (widget.product.isImageLocked(imageField, _currentLanguage) != true) { + return EMPTY_WIDGET; + } else { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + return IconButton( + onPressed: () { + showSmoothModalSheet( + context: context, + builder: (BuildContext context) { + return SmoothModalSheet( + title: appLocalizations.owner_field_info_title, + prefixIndicator: true, + body: SafeArea( + top: false, + child: Column( + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + shape: BoxShape.circle, + ), + padding: const EdgeInsetsDirectional.all(LARGE_SPACE), + child: const OwnerFieldIcon( + size: 30.0, + ), + ), + const SizedBox(height: MEDIUM_SPACE), + Text( + appLocalizations.owner_field_info_message, + style: const TextStyle(fontSize: 15.0), + ), + ], + ), + ), + ); + }); + }, + tooltip: appLocalizations.owner_field_info_title, + icon: const OwnerFieldIcon(), + ); + } + } }