diff --git a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart index e4c7f01456e8..8bfc76c9854c 100644 --- a/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart +++ b/packages/smooth_app/lib/generic_lib/bottom_sheets/smooth_bottom_sheet.dart @@ -111,10 +111,13 @@ Future showSmoothListOfChoicesModalSheet({ List? suffixIcons, Color? suffixIconTint, EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? contentPadding, EdgeInsetsGeometry? dividerPadding, TextStyle? textStyle, Color? headerBackgroundColor, + Color? footerBackgroundColor, Color? prefixIndicatorColor, + double footerSpace = 0.0, bool safeArea = false, }) { assert(labels.length == values.length); @@ -142,10 +145,11 @@ Future showSmoothListOfChoicesModalSheet({ labels.elementAt(i), style: textStyle ?? const TextStyle(fontWeight: FontWeight.w500), ), - contentPadding: EdgeInsetsDirectional.only( - start: LARGE_SPACE, - end: addEndArrowToItems ? 17.0 : LARGE_SPACE, - ), + contentPadding: contentPadding ?? + EdgeInsetsDirectional.only( + start: LARGE_SPACE, + end: addEndArrowToItems ? 17.0 : LARGE_SPACE, + ), trailing: (suffixIcons != null ? IconTheme.merge( data: IconThemeData(color: suffixIconTint), @@ -170,15 +174,34 @@ Future showSmoothListOfChoicesModalSheet({ } } - if (footer != null) { - items.add(footer); + double bottomPadding = MediaQuery.paddingOf(context).bottom; + + if (safeArea && bottomPadding == 0.0) { + bottomPadding = MediaQuery.viewPaddingOf(context).bottom; } - final double paddingHeight = MediaQuery.paddingOf(context).bottom; - items.add(SizedBox(height: paddingHeight)); + if (footer != null) { + if (footerSpace > 0.0) { + items.add(SizedBox(height: footerSpace)); + } + + Widget footerChild = Column( + children: [ + footer, + SizedBox(height: bottomPadding), + ], + ); + + if (footerBackgroundColor != null) { + footerChild = ColoredBox( + color: footerBackgroundColor, + child: footerChild, + ); + } - if (safeArea && paddingHeight == 0.0) { - items.add(SizedBox(height: MediaQuery.viewPaddingOf(context).bottom)); + items.add(footerChild); + } else { + items.add(SizedBox(height: bottomPadding)); } return showSmoothModalSheet( diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 4bbd74f2ec64..5ae3131eec45 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -538,6 +538,10 @@ "imageType": {} } }, + "outdated_image_short_label": "may be outdated", + "@outdated_image_short_label": { + "description": "A label for outdated images" + }, "ingredients": "Ingredients", "@ingredients": {}, "ingredients_editing_instructions": "Keep the original order. Indicate the percentage when specified. Separate with a comma or hyphen and use parentheses for ingredients of an ingredient.", @@ -851,6 +855,30 @@ "@product_image_action_choose_existing_photo": { "description": "Replace the existing picture with one from the product's photos" }, + "product_image_details_label": "Information about the photo", + "@product_image_details_label": { + "description": "Label for the photo details" + }, + "product_image_details_from_producer": "From the producer", + "@product_image_details_from_producer": { + "description": "Text to indicate that the image was taken by the producer" + }, + "product_image_details_contributor": "Contributor", + "@product_image_details_contributor": { + "description": "The name of the contributor who uploaded the image" + }, + "product_image_details_contributor_producer": "Contributor (producer)", + "@product_image_details_contributor": { + "description": "The name of the contributor (and also the owner field) who uploaded the image" + }, + "product_image_details_date": "Date", + "@product_image_details_date": { + "description": "Text to indicate the date of the image" + }, + "product_image_details_date_unknown": "Unknown", + "@product_image_details_date": { + "description": "Text to indicate the date of the image is unknown" + }, "homepage_main_card_logo_description": "Welcome to Open Food Facts", "@homepage_main_card_logo_description": { "description": "Description for accessibility of the Open Food Facts logo on the homepage" diff --git a/packages/smooth_app/lib/pages/image/product_image_other_page.dart b/packages/smooth_app/lib/pages/image/product_image_other_page.dart index 913dfcbf1f8e..62d5cc9d1f63 100644 --- a/packages/smooth_app/lib/pages/image/product_image_other_page.dart +++ b/packages/smooth_app/lib/pages/image/product_image_other_page.dart @@ -398,6 +398,7 @@ class _ProductImageDetailsButton extends StatelessWidget { ), subtitle: Text(image.contributor ?? '-'), ), + const Divider(), ListTile( title: Text( appLocalizations.photo_viewer_details_date_title), @@ -405,6 +406,7 @@ class _ProductImageDetailsButton extends StatelessWidget { ? DateFormat.yMMMMEEEEd().format(image.uploaded!) : '-'), ), + const Divider(), ListTile( title: Text( appLocalizations.photo_viewer_details_size_title), @@ -418,7 +420,8 @@ class _ProductImageDetailsButton extends StatelessWidget { : '-', ), ), - if (url.isNotEmpty) + if (url.isNotEmpty) ...[ + const Divider(), ListTile( title: Text(appLocalizations .photo_viewer_details_url_title), @@ -427,9 +430,11 @@ class _ProductImageDetailsButton extends StatelessWidget { onTap: () { LaunchUrlHelper.launchURL(url); }, - ), + ) + ], SizedBox( - height: MediaQuery.viewPaddingOf(context).bottom), + height: MediaQuery.viewPaddingOf(context).bottom, + ), ], ), ); 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 index 60137beef9d0..498dacb9e7ed 100644 --- 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 @@ -38,128 +38,159 @@ Future<_PhotoRowActions?> _showPhotoBanner({ 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, - ), - ], + contentPadding: const EdgeInsetsDirectional.symmetric( + horizontal: LARGE_SPACE, ), + addEndArrowToItems: true, + footerBackgroundColor: lightTheme ? extension.primaryLight : null, + footerSpace: VERY_SMALL_SPACE, + footer: transientFile.isImageAvailable() + ? _PhotoRowBanner( + product: product, + imageField: imageField, + language: language, + transientFile: transientFile, + ) + : null, ); 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({ +class _PhotoRowBanner extends StatefulWidget { + const _PhotoRowBanner({ + required this.product, + required this.imageField, + required this.language, required this.transientFile, }); + final Product product; + final ImageField imageField; + final OpenFoodFactsLanguage language; final TransientFile transientFile; @override - Widget build(BuildContext context) { - if (!transientFile.isImageAvailable() || - transientFile.uploadedDate == null) { - return EMPTY_WIDGET; - } + State<_PhotoRowBanner> createState() => _PhotoRowBannerState(); +} + +class _PhotoRowBannerState extends State<_PhotoRowBanner> { + late bool _expanded; + late final bool _dateInitiallyVisible; + late final bool _contributorInitiallyVisible; + + @override + void initState() { + super.initState(); + + _dateInitiallyVisible = _PhotoRowDate.isVisible(widget.transientFile); + _contributorInitiallyVisible = _PhotoRowContributor.isVisible( + widget.product, + widget.imageField, + widget.language, + ); + _expanded = _dateInitiallyVisible && _contributorInitiallyVisible; + } + @override + Widget build(BuildContext context) { final SmoothColorsThemeExtension extension = context.extension(); - final bool outdated = transientFile.expired; - - final AppLocalizations appLocalizations = AppLocalizations.of(context); + final bool lightTheme = context.lightTheme(); - 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, + return ListTileTheme.merge( + titleTextStyle: TextStyle( + inherit: true, + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: lightTheme ? Colors.black : Colors.white, + fontFamily: 'OpenSans', + ), + leadingAndTrailingTextStyle: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w500, + color: lightTheme ? Colors.black : Colors.white, + fontFamily: 'OpenSans', + ), + contentPadding: const EdgeInsetsDirectional.only( + start: BALANCED_SPACE, + end: LARGE_SPACE, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Material( + type: MaterialType.transparency, + child: InkWell( + onTap: !_expanded + ? () => setState(() => _expanded = !_expanded) + : null, + child: Ink( + width: double.infinity, + color: extension.primaryDark, + padding: const EdgeInsetsDirectional.symmetric( + horizontal: MEDIUM_SPACE, + vertical: MEDIUM_SPACE, ), - ), - 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, - ), + child: Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context) + .product_image_details_label, + style: const TextStyle( + color: Colors.white, + fontSize: 17.0, + fontWeight: FontWeight.bold, + ), + ), + ), + if (!_expanded) + Padding( + padding: const EdgeInsetsDirectional.only( + end: 9.0, + ), + child: Semantics( + value: MaterialLocalizations.of(context) + .expandedIconTapHint, + excludeSemantics: true, + child: const icons.Chevron.down( + size: 18.0, + color: Colors.white, + ), + ), + ) + ], ), + ), + ), ), - ), + if (_expanded || _dateInitiallyVisible) + _PhotoRowDate( + transientFile: widget.transientFile, + ), + if (_expanded) const Divider(color: Colors.white), + if (_expanded || _contributorInitiallyVisible) + _PhotoRowContributor( + product: widget.product, + imageField: widget.imageField, + language: widget.language, + ), + ], ), ); } - - 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({ +class _PhotoRowContributor extends StatelessWidget { + const _PhotoRowContributor({ required this.product, required this.imageField, required this.language, @@ -169,115 +200,168 @@ class _PhotoRowLockedStatus extends StatelessWidget { final ImageField imageField; final OpenFoodFactsLanguage language; + static bool isVisible(Product product, ImageField imageField, + OpenFoodFactsLanguage language) => + product.isImageLocked(imageField, language) == true; + @override Widget build(BuildContext context) { - if (product.isImageLocked(imageField, language) != true) { - return EMPTY_WIDGET; - } - + final AppLocalizations appLocalizations = AppLocalizations.of(context); final SmoothColorsThemeExtension extension = context.extension(); - final AppLocalizations appLocalizations = AppLocalizations.of(context); + final bool isLocked = isVisible(product, imageField, language); + final String? contributor = _contributor; - 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, + final String title; + final String value; + final Widget icon; + final EdgeInsetsGeometry padding; + + if (contributor?.isNotEmpty == true) { + title = isLocked + ? appLocalizations.product_image_details_contributor_producer + : appLocalizations.product_image_details_contributor; + value = contributor!; + } else { + title = appLocalizations.product_image_details_from_producer; + value = isLocked ? appLocalizations.yes : appLocalizations.no; + } + + if (isLocked) { + icon = const OwnerFieldIcon(size: 19.0); + padding = const EdgeInsetsDirectional.only(bottom: 1.0, end: 1.0); + } else { + icon = const Icon(Icons.person); + padding = EdgeInsets.zero; + } + + return ListTile( + leading: _PhotoRowDetailsIcon( + color: extension.primaryDark, + icon: icon, + padding: padding, ), + title: Text(title), + trailing: Text(value), ); } + + String? get _contributor { + if (product.images == null) { + return null; + } + + for (final ProductImage productImage in product.images!) { + if (productImage.field == imageField && + productImage.language == language) { + if (productImage.contributor != null) { + /// Always null in my tests + return productImage.contributor; + } + + /// Let's try to find by the image id + return product.images!.firstWhereOrNull((ProductImage img) { + return productImage.imgid == img.imgid; + })?.contributor; + } + } + + return null; + } } -/// 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, +/// The date of the photo (used in the modal sheet) +class _PhotoRowDate extends StatelessWidget { + const _PhotoRowDate({ + required this.transientFile, }); - final Widget icon; - final Color iconBackgroundColor; - final Widget text; - final bool wrapTextInExpanded; + final TransientFile transientFile; + + static bool isVisible(TransientFile transientFile) => transientFile.expired; @override Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(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, + final bool outdated = isVisible(transientFile); + + return ListTile( + leading: _PhotoRowDetailsIcon( + color: outdated ? extension.warning : extension.primaryDark, + icon: outdated ? _outdatedIcon : _successIcon, + padding: outdated + ? const EdgeInsetsDirectional.only( + top: 0.5, + end: 1.0, + ) + : null, + ), + title: Text(appLocalizations.date), + trailing: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + transientFile.uploadedDate != null + ? DateFormat.yMd().format(transientFile.uploadedDate!) + : appLocalizations.product_image_details_date_unknown, ), - 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, - ], + if (outdated) + Text( + '(${appLocalizations.outdated_image_short_label})', + style: const TextStyle(fontSize: 15.0), ), - ), - ), + ], ), ); } - Widget get _textWidget { - final Widget textWidget = Padding( - padding: const EdgeInsetsDirectional.only( - start: MEDIUM_SPACE, - end: VERY_LARGE_SPACE, - ), - child: text, - ); + Widget get _outdatedIcon => const Padding( + padding: EdgeInsetsDirectional.only( + bottom: 1.5, + start: 1.5, + ), + child: icons.Outdated(size: 19.0), + ); - if (wrapTextInExpanded) { - return Expanded( - child: textWidget, + Widget get _successIcon => const Padding( + padding: EdgeInsetsDirectional.only(bottom: 0.5), + child: icons.Clock(size: 19.0), ); - } +} + +class _PhotoRowDetailsIcon extends StatelessWidget { + const _PhotoRowDetailsIcon({ + required this.icon, + required this.color, + this.padding, + }); + + final Widget icon; + final Color color; + final EdgeInsetsGeometry? padding; - return textWidget; + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: SizedBox.square( + dimension: 35.0, + child: Padding( + padding: padding ?? const EdgeInsetsDirectional.all(SMALL_SPACE), + child: IconTheme.merge( + data: const IconThemeData(color: Colors.white), + child: icon, + ), + ), + ), + ); } } 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 a75b2d0ae025..a62ebe17464e 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 @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:auto_size_text/auto_size_text.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; @@ -18,7 +19,6 @@ import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_view 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'; import 'package:smooth_app/resources/app_animations.dart'; import 'package:smooth_app/resources/app_icons.dart' as icons; import 'package:smooth_app/themes/smooth_theme.dart'; 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 55a2b3c9d15f..eb6511d9e4ca 100644 --- a/packages/smooth_app/lib/pages/product/owner_field_info.dart +++ b/packages/smooth_app/lib/pages/product/owner_field_info.dart @@ -37,14 +37,20 @@ class OwnerFieldBanner extends StatelessWidget { /// Standard icon about "owner fields". class OwnerFieldIcon extends StatelessWidget { - const OwnerFieldIcon({this.size, super.key}); + const OwnerFieldIcon({ + this.size, + this.color, + super.key, + }); final double? size; + final Color? color; @override Widget build(BuildContext context) => Icon( _ownerFieldIconData, size: size, + color: color, semanticLabel: AppLocalizations.of(context).owner_field_info_title, ); }