diff --git a/packages/smooth_app/lib/cards/product_cards/product_title_card.dart b/packages/smooth_app/lib/cards/product_cards/product_title_card.dart index 676c6b1b98b..8b4726d7aba 100644 --- a/packages/smooth_app/lib/cards/product_cards/product_title_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/product_title_card.dart @@ -1,23 +1,13 @@ -import 'dart:ui'; - -import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_base_card.dart'; -import 'package:smooth_app/database/transient_file.dart'; +import 'package:smooth_app/cards/product_cards/smooth_product_image.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/helpers/extension_on_text_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; -import 'package:smooth_app/pages/image/product_image_helper.dart'; import 'package:smooth_app/pages/product/product_image_gallery_view.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; -import 'package:smooth_app/themes/smooth_theme_colors.dart'; -import 'package:smooth_app/themes/theme_provider.dart'; class ProductTitleCard extends StatelessWidget { const ProductTitleCard( @@ -64,6 +54,9 @@ class ProductTitleCard extends StatelessWidget { ), ]; } else { + final Size imageSize = + Size.square(MediaQuery.sizeOf(context).width * 0.25); + children = [ Padding( padding: const EdgeInsetsDirectional.only(top: SMALL_SPACE), @@ -71,7 +64,31 @@ class ProductTitleCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _ProductTitleCardPicture(), + TooltipTheme( + data: TooltipThemeData( + verticalOffset: imageSize.width / 2, + preferBelow: true, + ), + child: ProductPicture( + product: product, + imageField: ImageField.FRONT, + fallbackUrl: product.imageFrontUrl, + size: imageSize, + showObsoleteIcon: true, + imageFoundBorder: 1.0, + imageNotFoundBorder: 1.0, + borderRadius: BorderRadius.circular(14.0), + onTap: () async => Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => + ProductImageGalleryView( + product: product, + ), + ), + ), + ), + ), Expanded( child: Padding( padding: const EdgeInsetsDirectional.only( @@ -220,289 +237,3 @@ class _ProductTitleCardTrailing extends StatelessWidget { } } } - -class _ProductTitleCardPicture extends StatefulWidget { - const _ProductTitleCardPicture(); - - @override - State<_ProductTitleCardPicture> createState() => - _ProductTitleCardPictureState(); -} - -class _ProductTitleCardPictureState extends State<_ProductTitleCardPicture> { - bool _imageError = false; - - @override - Widget build(BuildContext context) { - final Product product = context.watch(); - final (ImageProvider, bool)? imageProvider = getImageProvider(product); - final double size = MediaQuery.sizeOf(context).width * 0.25; - - final Widget inkWell = InkWell( - onTap: () async => Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext context) => ProductImageGalleryView( - product: product, - ), - ), - ), - splashColor: getSplashColor(context), - ); - - Widget child; - if (_imageError) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - - child = _ProductTitleCardPictureAssetsSvg( - asset: 'assets/product/product_error.svg', - semanticsLabel: - appLocalizations.product_page_image_error_accessibility_label, - text: appLocalizations.product_page_image_error, - textStyle: TextStyle( - color: Theme.of(context).extension()!.red, - ), - size: size, - child: inkWell, - ); - } else if (imageProvider != null) { - child = _ProductTitleCardPictureWithProvider( - imageProvider: imageProvider.$1, - outdated: imageProvider.$2, - size: size, - onError: () => setState(() => _imageError = true), - child: inkWell, - ); - } else { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - - child = _ProductTitleCardPictureAssetsSvg( - asset: 'assets/product/product_not_found_text.svg', - semanticsLabel: appLocalizations - .product_page_image_no_image_available_accessibility_label, - text: appLocalizations.product_page_image_no_image_available, - textStyle: TextStyle( - color: Theme.of(context) - .extension()! - .primaryDark, - ), - size: size, - child: inkWell, - ); - } - - return ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(14.0)), - child: child, - ); - } - - Color? getSplashColor(BuildContext context) { - try { - return context.read().color?.withOpacity(0.5); - } catch (_) { - return null; - } - } - - /// Returns the image provider for the product. - /// If this is a [TransientFile], the boolean indicates whether the image is - /// outdated or not. - (ImageProvider, bool)? getImageProvider(Product product) { - final TransientFile transientFile = TransientFile.fromProductImageData( - getProductImageData( - product, - ImageField.FRONT, - ProductQuery.getLanguage(), - ), - product.barcode!, - ProductQuery.getLanguage(), - ); - final ImageProvider? imageProvider = transientFile.getImageProvider(); - - if (imageProvider != null) { - return (imageProvider, transientFile.expired); - } else if (product.imageFrontUrl?.isNotEmpty == true) { - return (NetworkImage(product.imageFrontUrl!), false); - } else { - return null; - } - } -} - -class _ProductTitleCardPictureWithProvider extends StatelessWidget { - const _ProductTitleCardPictureWithProvider({ - required this.imageProvider, - required this.outdated, - required this.size, - required this.child, - required this.onError, - }); - - final ImageProvider imageProvider; - final bool outdated; - final double size; - final Widget child; - final VoidCallback onError; - - @override - Widget build(BuildContext context) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - - final Widget image = Semantics( - label: appLocalizations.product_page_image_front_accessibility_label, - image: true, - excludeSemantics: true, - child: SizedBox.square( - dimension: size, - child: Stack( - children: [ - ColoredBox( - color: Colors.white, - child: Opacity( - opacity: context.lightTheme() ? 0.2 : 0.65, - child: ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), - child: Image( - image: imageProvider, - fit: BoxFit.cover, - height: size, - width: size, - ), - ), - ), - ), - Image( - width: size, - height: size, - fit: BoxFit.contain, - image: imageProvider, - errorBuilder: (_, __, ___) { - onError.call(); - return EMPTY_WIDGET; - }, - ), - Material( - type: MaterialType.transparency, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(14.0)), - border: Border.all( - color: Theme.of(context).dividerColor, - width: 1.0, - ), - ), - child: child), - ) - ], - ), - ), - ); - - if (!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, - verticalOffset: size / 2, - preferBelow: true, - child: Stack( - children: [ - image, - Positioned.directional( - bottom: 6.0, - end: 6.0, - textDirection: Directionality.of(context), - child: const icons.Outdated( - size: 15.0, - color: Colors.black38, - shadow: Shadow( - color: Colors.white38, - blurRadius: 2.0, - ), - ), - ), - ], - ), - ), - ); - } - - return image; - } -} - -class _ProductTitleCardPictureAssetsSvg extends StatelessWidget { - const _ProductTitleCardPictureAssetsSvg({ - required this.asset, - required this.semanticsLabel, - required this.text, - required this.textStyle, - required this.size, - required this.child, - }) : assert(asset.length > 0), - assert(size > 0.0); - - final String asset; - final String semanticsLabel; - final String? text; - final TextStyle? textStyle; - final double size; - final Widget child; - - @override - Widget build(BuildContext context) { - return Semantics( - label: semanticsLabel, - image: true, - excludeSemantics: true, - child: SizedBox.square( - dimension: size, - child: Stack( - children: [ - Positioned.fill( - child: SvgPicture.asset( - asset, - width: size, - height: size, - ), - ), - if (text != null) - Padding( - padding: const EdgeInsets.all(SMALL_SPACE), - child: AutoSizeText( - text!, - maxLines: 2, - minFontSize: 5.0, - style: const TextStyle( - fontSize: 14.0, - fontWeight: FontWeight.w600, - height: 1.2, - ).merge(textStyle), - ), - ), - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(14.0)), - border: Border.all( - color: (textStyle?.color ?? Theme.of(context).dividerColor) - .withOpacity(0.2), - width: 1.0, - ), - ), - child: Material( - type: MaterialType.transparency, - child: child, - ), - ), - ), - ], - ), - ), - ); - } -} 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 new file mode 100644 index 00000000000..e1cd1daaffb --- /dev/null +++ b/packages/smooth_app/lib/cards/product_cards/smooth_product_image.dart @@ -0,0 +1,399 @@ +import 'dart:ui'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +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/pages/image/product_image_helper.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; +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'; + +class ProductPicture extends StatefulWidget { + ProductPicture({ + required this.product, + required this.imageField, + required this.size, + this.fallbackUrl, + this.heroTag, + this.onTap, + this.borderRadius, + this.imageFoundBorder = 0.0, + this.imageNotFoundBorder = 0.0, + this.errorTextStyle, + this.showObsoleteIcon = false, + super.key, + }) : assert(imageFoundBorder >= 0.0), + assert(imageNotFoundBorder >= 0.0), + assert(heroTag == null || heroTag.isNotEmpty), + assert(size.width >= 0.0 && size.height >= 0.0); + + final Product product; + final ImageField imageField; + final Size size; + final String? fallbackUrl; + final VoidCallback? onTap; + + final String? heroTag; + + /// Show the obsolete icon on top of the image + final bool showObsoleteIcon; + + /// Rounded borders around the image + final BorderRadius? borderRadius; + final double imageFoundBorder; + final double imageNotFoundBorder; + + /// Style when there is no image/an error + final TextStyle? errorTextStyle; + + @override + State createState() => _ProductPictureState(); +} + +class _ProductPictureState extends State { + bool _imageError = false; + + @override + Widget build(BuildContext context) { + final (ImageProvider, bool)? imageProvider = _getImageProvider( + widget.product, + ); + + final Widget? inkWell = widget.onTap != null + ? InkWell( + onTap: widget.onTap, + splashColor: _getSplashColor(context), + ) + : null; + + Widget child; + if (_imageError) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + child = _ProductPictureAssetsSvg( + asset: 'assets/product/product_error.svg', + semanticsLabel: + appLocalizations.product_page_image_error_accessibility_label, + text: appLocalizations.product_page_image_error, + textStyle: TextStyle( + color: context.extension().red, + ).merge(widget.errorTextStyle ?? const TextStyle()), + size: widget.size, + borderRadius: widget.borderRadius, + border: widget.imageNotFoundBorder, + child: inkWell, + ); + } else if (imageProvider != null) { + child = _ProductPictureWithImageProvider( + imageProvider: imageProvider.$1, + outdated: imageProvider.$2, + heroTag: widget.heroTag, + size: widget.size, + showOutdated: widget.showObsoleteIcon, + borderRadius: widget.borderRadius, + border: widget.imageFoundBorder, + onError: () => setState(() => _imageError = true), + child: inkWell, + ); + } else { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + child = _ProductPictureAssetsSvg( + asset: 'assets/product/product_not_found_text.svg', + semanticsLabel: appLocalizations + .product_page_image_no_image_available_accessibility_label, + text: appLocalizations.product_page_image_no_image_available, + textStyle: TextStyle( + color: context.extension().primaryDark, + ).merge(widget.errorTextStyle ?? const TextStyle()), + borderRadius: widget.borderRadius, + border: widget.imageNotFoundBorder, + size: widget.size, + child: inkWell, + ); + } + + if (widget.borderRadius != null) { + return ClipRRect( + borderRadius: widget.borderRadius!, + child: child, + ); + } else { + return child; + } + } + + /// The splash tries to use the product compatibility as the accent color + Color? _getSplashColor(BuildContext context) { + try { + return context.read().color?.withOpacity(0.5); + } catch (_) { + return null; + } + } + + /// Returns the image provider for the product. + /// If this is a [TransientFile], the boolean indicates whether the image is + /// outdated or not. + (ImageProvider, bool)? _getImageProvider(Product product) { + final TransientFile transientFile = TransientFile.fromProduct( + product, + widget.imageField, + ProductQuery.getLanguage(), + ); + final ImageProvider? imageProvider = transientFile.getImageProvider(); + + if (imageProvider != null) { + return (imageProvider, transientFile.expired); + } else if (widget.fallbackUrl?.isNotEmpty == true) { + return (NetworkImage(widget.fallbackUrl!), false); + } else { + return null; + } + } +} + +class _ProductPictureWithImageProvider extends StatelessWidget { + const _ProductPictureWithImageProvider({ + required this.imageProvider, + required this.outdated, + required this.size, + required this.child, + required this.onError, + required this.showOutdated, + required this.border, + this.borderRadius, + this.heroTag, + }); + + final ImageProvider imageProvider; + final bool outdated; + final Size size; + final Widget? child; + final VoidCallback onError; + final bool showOutdated; + final BorderRadius? borderRadius; + final double border; + final String? heroTag; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + final Widget image = Semantics( + label: appLocalizations.product_page_image_front_accessibility_label, + image: true, + excludeSemantics: true, + child: SizedBox.fromSize( + size: size, + child: Stack( + children: [ + Positioned.fill( + child: ColoredBox( + color: Colors.white, + child: ClipRRect( + child: Opacity( + opacity: context.lightTheme() ? 0.2 : 0.65, + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), + child: Image( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + ), + ), + ), + Positioned.fill( + child: _buildImage(), + ), + if (child != null) + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: border > 0.0 + ? Border.all( + color: Theme.of(context).dividerColor, + width: 1.0, + ) + : null, + ), + child: child, + ), + ), + ), + ], + ), + ), + ); + + 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), + ), + ), + ), + ), + ], + ), + ), + ); + } + + return image; + } + + Widget _buildImage() { + final Widget image = Image( + width: size.width, + height: size.height, + fit: BoxFit.contain, + image: imageProvider, + loadingBuilder: (_, Widget child, ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) { + return child; + } + return Center( + child: CircularProgressIndicator.adaptive( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + errorBuilder: (_, __, ___) { + onError.call(); + return EMPTY_WIDGET; + }, + ); + + if (heroTag != null) { + return Hero( + tag: heroTag!, + child: image, + ); + } else { + return image; + } + } +} + +class _ProductPictureAssetsSvg extends StatelessWidget { + _ProductPictureAssetsSvg({ + required this.asset, + required this.semanticsLabel, + required this.text, + required this.textStyle, + required this.size, + required this.child, + this.borderRadius, + this.border = 0.0, + }) : assert(asset.isNotEmpty), + assert(size.width > 0.0 && size.height > 0.0); + + final String asset; + final String semanticsLabel; + final String? text; + final TextStyle? textStyle; + final Size size; + final Widget? child; + final BorderRadius? borderRadius; + final double border; + + @override + Widget build(BuildContext context) { + return Semantics( + label: semanticsLabel, + image: true, + excludeSemantics: true, + child: SizedBox.fromSize( + size: size, + child: Stack( + children: [ + Positioned.fill( + child: SvgPicture.asset( + asset, + width: size.width, + height: size.height, + fit: BoxFit.cover, + ), + ), + if (text != null) + Padding( + padding: const EdgeInsetsDirectional.all(SMALL_SPACE), + child: AutoSizeText( + text!, + maxLines: 2, + minFontSize: 5.0, + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w600, + height: 1.2, + ).merge(textStyle), + ), + ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: border > 0.0 + ? Border.all( + color: (textStyle?.color ?? + Theme.of(context).dividerColor) + .withOpacity(0.2), + width: 1.0, + ) + : null, + ), + child: Material( + type: MaterialType.transparency, + child: child, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/helpers/product_cards_helper.dart b/packages/smooth_app/lib/helpers/product_cards_helper.dart index c71f5a576c9..751390ce610 100644 --- a/packages/smooth_app/lib/helpers/product_cards_helper.dart +++ b/packages/smooth_app/lib/helpers/product_cards_helper.dart @@ -18,6 +18,8 @@ SmoothAppBar buildEditProductAppBar({ required final BuildContext context, required final String title, required final Product product, + final PreferredSizeWidget? bottom, + final List? actions, }) => SmoothAppBar( centerTitle: false, @@ -31,6 +33,8 @@ SmoothAppBar buildEditProductAppBar({ maxLines: 1, overflow: TextOverflow.ellipsis, ), + actions: actions, + bottom: bottom, ignoreSemanticsForSubtitle: true, ); diff --git a/packages/smooth_app/lib/pages/image/product_image_widget.dart b/packages/smooth_app/lib/pages/image/product_image_widget.dart index f52b9566894..8744900113c 100644 --- a/packages/smooth_app/lib/pages/image/product_image_widget.dart +++ b/packages/smooth_app/lib/pages/image/product_image_widget.dart @@ -9,9 +9,10 @@ import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/pages/image/product_image_helper.dart'; import 'package:smooth_app/query/product_query.dart'; import 'package:smooth_app/resources/app_icons.dart' as icons; +import 'package:smooth_app/themes/smooth_theme.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; -/// Displays a product image thumbnail with the upload date on top. +/// Displays a product image thumbnail with the upload date. class ProductImageWidget extends StatelessWidget { const ProductImageWidget({ required this.productImage, @@ -34,7 +35,7 @@ class ProductImageWidget extends StatelessWidget { @override Widget build(BuildContext context) { final SmoothColorsThemeExtension colors = - Theme.of(context).extension()!; + context.extension(); final AppLocalizations appLocalizations = AppLocalizations.of(context); final DateFormat dateFormat = DateFormat.yMd(ProductQuery.getLanguage().offTag); @@ -104,7 +105,7 @@ class ProductImageWidget extends StatelessWidget { textDirection: Directionality.of(context), child: icons.Outdated( size: 18.0, - color: colors.red, + color: colors.orange, ), ), ], 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 f295ae05d5a..84751b5d0d8 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,13 +1,14 @@ +import 'package:auto_size_text/auto_size_text.dart'; 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/cards/product_cards/smooth_product_image.dart'; import 'package:smooth_app/data_models/up_to_date_mixin.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/transient_file.dart'; import 'package:smooth_app/generic_lib/buttons/smooth_large_button_with_icon.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/generic_lib/widgets/smooth_app_logo.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; @@ -20,7 +21,9 @@ import 'package:smooth_app/pages/product/common/product_refresher.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/resources/app_icons.dart'; +import 'package:smooth_app/themes/smooth_theme.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/widgets/slivers.dart'; import 'package:smooth_app/widgets/smooth_scaffold.dart'; @@ -64,42 +67,50 @@ class _ProductImageGalleryViewState extends State context: context, title: appLocalizations.edit_product_form_item_photos_title, product: upToDateProduct, - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () async { - AnalyticsHelper.trackProductEdit( - AnalyticsEditEvents.photos, - upToDateProduct, - true, - ); - await confirmAndUploadNewPicture( - context, - imageField: ImageField.OTHER, - barcode: barcode, - language: ProductQuery.getLanguage(), - isLoggedInMandatory: true, - productType: upToDateProduct.productType, - ); - }, - label: Text(appLocalizations.add_photo_button_label), - icon: const Icon(Icons.add_a_photo), + bottom: PreferredSize( + preferredSize: const Size(double.infinity, 50.0), + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 55.0), + child: 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, + ), + ), + ), + ), + actions: [ + IconButton( + onPressed: () async { + AnalyticsHelper.trackProductEdit( + AnalyticsEditEvents.photos, + upToDateProduct, + true, + ); + await confirmAndUploadNewPicture( + context, + imageField: ImageField.OTHER, + barcode: barcode, + language: ProductQuery.getLanguage(), + isLoggedInMandatory: true, + productType: upToDateProduct.productType, + ); + }, + tooltip: appLocalizations.add_photo_button_label, + icon: const Icon(Icons.add_a_photo), + ), + ], ), body: 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, - ), - ), Expanded( child: RefreshIndicator( onRefresh: () async => ProductRefresher().fetchAndRefresh( @@ -112,18 +123,31 @@ class _ProductImageGalleryViewState extends State gridDelegate: SliverGridDelegateWithFixedCrossAxisCountAndFixedHeight( crossAxisCount: 2, - height: _computeItemHeight(), + height: (MediaQuery.sizeOf(context).width / 2.15) + + _PhotoRow.itemHeight, ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { - return _PhotoRow( - position: index, - imageField: _mainImageFields[index], - product: upToDateProduct, - language: _language, + return Padding( + padding: EdgeInsetsDirectional.only( + top: VERY_SMALL_SPACE, + start: index.isOdd + ? VERY_SMALL_SPACE / 2 + : VERY_SMALL_SPACE, + end: index.isOdd + ? VERY_SMALL_SPACE + : VERY_SMALL_SPACE / 2, + ), + child: _PhotoRow( + position: index, + imageField: _mainImageFields[index], + product: upToDateProduct, + language: _language, + ), ); }, childCount: _mainImageFields.length, + addAutomaticKeepAlives: false, ), ), SliverPadding( @@ -153,18 +177,6 @@ class _ProductImageGalleryViewState extends State ), ), ), - // Extra space to be above the FAB - SliverFillRemaining( - hasScrollBody: false, - child: SizedBox( - height: (Theme.of(context) - .floatingActionButtonTheme - .extendedSizeConstraints - ?.maxHeight ?? - 56.0) + - 16.0, - ), - ), ], ), ), @@ -175,14 +187,6 @@ class _ProductImageGalleryViewState extends State ); } - double _computeItemHeight() { - final TextStyle? textStyle = Theme.of(context).textTheme.headlineMedium; - - return (MediaQuery.sizeOf(context).width / 2) + - SMALL_SPACE + - ((textStyle?.fontSize ?? 15.0) * 2) * (textStyle?.height ?? 2.0); - } - bool _shouldDisplayRawGallery() => _clickedOtherPictureButton || (upToDateProduct.getRawImages()?.isNotEmpty == true); @@ -196,6 +200,8 @@ class _PhotoRow extends StatelessWidget { required this.imageField, }); + static double itemHeight = 55.0; + final int position; final Product product; final OpenFoodFactsLanguage language; @@ -210,6 +216,9 @@ class _PhotoRow extends StatelessWidget { final AppLocalizations appLocalizations = AppLocalizations.of(context); final String label = imageField.getProductImageTitle(appLocalizations); + final SmoothColorsThemeExtension extension = + context.extension(); + return Semantics( image: true, button: true, @@ -217,59 +226,91 @@ class _PhotoRow extends StatelessWidget { ? appLocalizations.product_image_outdated_accessibility_label(label) : label, excludeSemantics: true, - child: Padding( - padding: const EdgeInsets.only( - top: SMALL_SPACE, - ), + child: Material( + elevation: 1.0, + type: MaterialType.card, + color: extension.primaryBlack, + borderRadius: ANGULAR_BORDER_RADIUS, child: InkWell( + borderRadius: ANGULAR_BORDER_RADIUS, onTap: () => _openImage( context: context, initialImageIndex: position, ), - child: Column( - children: [ - Stack( - children: [ - AspectRatio( - aspectRatio: 1.0, - child: SmoothImage( - rounded: false, - imageProvider: transientFile.getImageProvider(), - ), - ), - if (transientFile.isImageAvailable() && - !transientFile.isServerImage()) - const Center( - child: CloudUploadAnimation.circle(size: 30.0), - ), - if (expired) - Positioned.directional( - textDirection: Directionality.of(context), - bottom: VERY_SMALL_SPACE, - end: VERY_SMALL_SPACE, - child: const icons.Outdated( - color: Colors.black87, - shadow: Shadow( - color: Colors.white38, - blurRadius: 2.0, + child: ClipRRect( + borderRadius: ANGULAR_BORDER_RADIUS, + child: Column( + children: [ + SizedBox( + height: itemHeight, + child: Row( + children: [ + _PhotoRowIndicator(transientFile: transientFile), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: SMALL_SPACE, + ), + child: Row( + children: [ + Expanded( + child: AutoSizeText( + label, + maxLines: 2, + minFontSize: 10.0, + style: const TextStyle( + fontSize: 15.0, + height: 1.2, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(width: SMALL_SPACE), + CircledArrow.right( + color: extension.primaryDark, + type: CircledArrowType.normal, + circleColor: Colors.white, + size: 20.0, + ), + ], + ), ), ), - ), - ], - ), - Expanded( - child: Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - label, - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ), + ], ), ), - ), - ], + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: LayoutBuilder(builder: + (BuildContext context, BoxConstraints box) { + return ProductPicture( + product: product, + imageField: imageField, + size: Size(box.maxWidth, box.maxHeight), + onTap: null, + errorTextStyle: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w600, + ), + heroTag: ProductImageSwipeableView.getHeroTag( + imageField, + ), + ); + }), + ), + if (transientFile.isImageAvailable() && + !transientFile.isServerImage()) + const Center( + child: CloudUploadAnimation.circle(size: 50.0), + ), + ], + ), + ), + ], + ), ), ), ), @@ -301,3 +342,60 @@ class _PhotoRow extends StatelessWidget { language, ); } + +class _PhotoRowIndicator extends StatelessWidget { + const _PhotoRowIndicator({ + required this.transientFile, + }); + + final TransientFile transientFile; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 30.0, + height: double.infinity, + child: Ink( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: ANGULAR_RADIUS, + ), + color: _getColor( + context.extension(), + ), + ), + child: Center(child: child()), + ), + ); + } + + Widget? child() { + if (transientFile.isImageAvailable()) { + if (transientFile.expired) { + return const Outdated( + size: 18.0, + color: Colors.white, + ); + } else { + return null; + } + } else { + return const Warning( + size: 15.0, + color: Colors.white, + ); + } + } + + Color _getColor(SmoothColorsThemeExtension extension) { + if (transientFile.isImageAvailable()) { + if (transientFile.expired) { + return extension.orange; + } else { + return extension.green; + } + } else { + return extension.red; + } + } +} 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 f37adb6e61e..bb5e36740d3 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 @@ -42,6 +42,9 @@ class ProductImageSwipeableView extends StatefulWidget { @override State createState() => _ProductImageSwipeableViewState(); + + static String getHeroTag(ImageField imageField) => + 'photo_${imageField.offTag}'; } class _ProductImageSwipeableViewState extends State @@ -74,6 +77,7 @@ class _ProductImageSwipeableViewState extends State @override Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); + context.watch(); refreshUpToDate(); return SmoothScaffold( 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 5803ea100f7..a92704061b0 100644 --- a/packages/smooth_app/lib/pages/product/product_image_viewer.dart +++ b/packages/smooth_app/lib/pages/product/product_image_viewer.dart @@ -14,6 +14,7 @@ import 'package:smooth_app/generic_lib/widgets/picture_not_found.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/product_image_button.dart'; +import 'package:smooth_app/pages/product/product_image_swipeable_view.dart'; import 'package:smooth_app/resources/app_animations.dart'; /// Displays a full-screen image with an "edit" floating button. @@ -141,7 +142,9 @@ class _ProductImageViewerState extends State minScale: 0.2, imageProvider: imageProvider, heroAttributes: PhotoViewHeroAttributes( - tag: 'photo_${widget.imageField.offTag}', + tag: ProductImageSwipeableView.getHeroTag( + widget.imageField, + ), flightShuttleBuilder: ( _, Animation animation, diff --git a/packages/smooth_app/lib/resources/app_icons.dart b/packages/smooth_app/lib/resources/app_icons.dart index b73b7d8a3a1..3e67592323e 100644 --- a/packages/smooth_app/lib/resources/app_icons.dart +++ b/packages/smooth_app/lib/resources/app_icons.dart @@ -301,46 +301,127 @@ class ClearText extends AppIcon { class CircledArrow extends AppIcon { const CircledArrow.right({ - super.color, - super.size, - super.shadow, - super.key, - }) : turns = 0, - super._(_IconsFont.circled_arrow); + CircledArrowType? type, + Color? circleColor, + Color? color, + double? size, + Shadow? shadow, + Key? key, + }) : this._base( + type: type, + turns: 0, + circleColor: circleColor, + color: color, + size: size, + shadow: shadow, + key: key, + ); const CircledArrow.left({ - super.color, - super.size, - super.shadow, - super.key, - }) : turns = 2, - super._(_IconsFont.circled_arrow); + CircledArrowType? type, + Color? circleColor, + Color? color, + double? size, + Shadow? shadow, + Key? key, + }) : this._base( + type: type, + turns: 2, + circleColor: circleColor, + color: color, + size: size, + shadow: shadow, + key: key, + ); const CircledArrow.down({ - super.color, - super.size, - super.shadow, - super.key, - }) : turns = 1, - super._(_IconsFont.circled_arrow); + CircledArrowType? type, + Color? circleColor, + Color? color, + double? size, + Shadow? shadow, + Key? key, + }) : this._base( + type: type, + turns: 1, + circleColor: circleColor, + color: color, + size: size, + shadow: shadow, + key: key, + ); const CircledArrow.up({ - super.color, - super.size, - super.shadow, - super.key, - }) : turns = 3, - super._(_IconsFont.circled_arrow); + CircledArrowType? type, + Color? circleColor, + Color? color, + double? size, + Shadow? shadow, + Key? key, + }) : this._base( + type: type, + turns: 3, + circleColor: circleColor, + color: color, + size: size, + shadow: shadow, + key: key, + ); + + const CircledArrow._base({ + CircledArrowType? type, + required this.turns, + this.circleColor, + super.color, + super.size, + super.shadow, + super.key, + }) : assert( + (circleColor == null && + (type == null || type == CircledArrowType.thin)) || + (circleColor != null && type == CircledArrowType.normal), + 'circleColor is only support and must be provided when type = CircledArrowType.normal', + ), + type = type ?? CircledArrowType.thin, + super._( + type == CircledArrowType.thin + ? _IconsFont.circled_arrow + : _IconsFont.arrow_right, + ); final int turns; + final CircledArrowType type; + final Color? circleColor; @override Widget build(BuildContext context) { - return RotatedBox( + final Widget child = RotatedBox( quarterTurns: turns, child: super.build(context), ); + + if (type == CircledArrowType.normal) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: circleColor, + ), + padding: const EdgeInsets.all(4.0), + child: child, + ); + } else { + return child; + } } + + @override + double? get size => + type == CircledArrowType.thin ? super.size : ((super.size ?? 20.0) - 8.0); +} + +enum CircledArrowType { + thin, + normal, } class Close extends AppIcon { @@ -1052,16 +1133,18 @@ class Warning extends AppIcon { abstract class AppIcon extends StatelessWidget { const AppIcon._( this.icon, { - this.color, + Color? color, this.shadow, - this.size, + double? size, this.semanticLabel, super.key, - }) : assert(size == null || size >= 0); + }) : _size = size, + _color = color, + assert(size == null || size >= 0); final IconData icon; - final Color? color; - final double? size; + final Color? _color; + final double? _size; final Shadow? shadow; final String? semanticLabel; @@ -1084,7 +1167,7 @@ abstract class AppIcon extends StatelessWidget { return Icon( icon, color: color, - size: size ?? iconTheme?.size, + size: size ?? iconTheme?.size ?? iconThemeData.size, semanticLabel: iconTheme?.semanticLabel ?? semanticLabel, shadows: shadow != null ? [shadow!] @@ -1093,6 +1176,12 @@ abstract class AppIcon extends StatelessWidget { : null, ); } + + /// Allow to override the size by a children + double? get size => _size; + + /// Allow to override the color tint by a children + Color? get color => _color; } /// Allows to override the default theme of an [AppIcon] diff --git a/packages/smooth_app/lib/themes/smooth_theme.dart b/packages/smooth_app/lib/themes/smooth_theme.dart index a07871827aa..af98988a364 100644 --- a/packages/smooth_app/lib/themes/smooth_theme.dart +++ b/packages/smooth_app/lib/themes/smooth_theme.dart @@ -250,3 +250,9 @@ class SmoothTheme { return hslDark.toColor(); } } + +extension SmoothThemeExtension on BuildContext { + T extension() { + return Theme.of(this).extension()!; + } +} diff --git a/packages/smooth_app/lib/widgets/smooth_app_bar.dart b/packages/smooth_app/lib/widgets/smooth_app_bar.dart index 7f9e6a49824..74091362222 100644 --- a/packages/smooth_app/lib/widgets/smooth_app_bar.dart +++ b/packages/smooth_app/lib/widgets/smooth_app_bar.dart @@ -145,6 +145,7 @@ class SmoothAppBar extends StatelessWidget implements PreferredSizeWidget { title: title!, subTitle: subTitle, titleTextStyle: titleTextStyle, + color: foregroundColor, ) : null, actions: actions, @@ -262,6 +263,7 @@ class _AppBarTitle extends StatelessWidget { required this.title, required this.titleTextStyle, required this.subTitle, + this.color, this.ignoreSemanticsForSubtitle, }); @@ -269,6 +271,7 @@ class _AppBarTitle extends StatelessWidget { final TextStyle? titleTextStyle; final Widget? subTitle; final bool? ignoreSemanticsForSubtitle; + final Color? color; @override Widget build(BuildContext context) { @@ -278,18 +281,19 @@ class _AppBarTitle extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ DefaultTextStyle( - style: titleTextStyle ?? - AppBarTheme.of(context).titleTextStyle ?? - theme.appBarTheme.titleTextStyle?.copyWith( - fontWeight: FontWeight.w500, - ) ?? - theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w500, - ) ?? - const TextStyle( - fontSize: 20.0, - fontWeight: FontWeight.w500, - ), + style: (titleTextStyle ?? + AppBarTheme.of(context).titleTextStyle ?? + theme.appBarTheme.titleTextStyle?.copyWith( + fontWeight: FontWeight.w500, + ) ?? + theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w500, + ) ?? + const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.w500, + )) + .copyWith(color: color), child: title, ), if (subTitle != null)