From 2bffec147e84cd1b3da6a41d9e9203aa6ac28bad Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Mon, 18 Nov 2024 13:24:40 +0100 Subject: [PATCH] Photo gallery with tabs (#5872) --- .../product_image_carousel_item.dart | 2 +- .../product_cards/product_title_card.dart | 4 +- .../product_cards/smooth_product_image.dart | 88 +++- .../widgets/language_selector.dart | 9 +- .../lib/helpers/border_radius_helper.dart | 32 ++ .../smooth_app/lib/helpers/ui_helpers.dart | 43 ++ packages/smooth_app/lib/l10n/app_en.arb | 4 + .../smooth_app/lib/pages/image_crop_page.dart | 134 +++--- .../user_preferences_languages_list.dart | 8 +- .../lib/pages/product/edit_product_page.dart | 2 +- .../product_image_gallery_photo_row.dart | 222 ++++++++++ .../product_image_gallery_tabs.dart | 314 ++++++++++++++ .../product_image_gallery_view.dart | 281 ++++++++++++ .../product/product_image_gallery_view.dart | 401 ------------------ .../smooth_app/lib/query/product_query.dart | 2 + .../lib/widgets/smooth_scaffold.dart | 1 + .../smooth_app/lib/widgets/smooth_tabbar.dart | 223 ++++++++++ 17 files changed, 1287 insertions(+), 483 deletions(-) create mode 100644 packages/smooth_app/lib/helpers/border_radius_helper.dart create mode 100644 packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_photo_row.dart create mode 100644 packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart create mode 100644 packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart delete mode 100644 packages/smooth_app/lib/pages/product/product_image_gallery_view.dart create mode 100644 packages/smooth_app/lib/widgets/smooth_tabbar.dart diff --git a/packages/smooth_app/lib/cards/data_cards/product_image_carousel_item.dart b/packages/smooth_app/lib/cards/data_cards/product_image_carousel_item.dart index 6ce8405bd0d2..a6364d07aced 100644 --- a/packages/smooth_app/lib/cards/data_cards/product_image_carousel_item.dart +++ b/packages/smooth_app/lib/cards/data_cards/product_image_carousel_item.dart @@ -7,7 +7,7 @@ import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/transient_file.dart'; import 'package:smooth_app/helpers/image_field_extension.dart'; import 'package:smooth_app/pages/image_crop_page.dart'; -import 'package:smooth_app/pages/product/product_image_gallery_view.dart'; +import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_view.dart'; import 'package:smooth_app/query/product_query.dart'; /// Displays a product image in the carousel: access to gallery, or new image. 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 8b4726d7aba5..b3cca9ac3598 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 @@ -7,7 +7,7 @@ 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/product/product_image_gallery_view.dart'; +import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_view.dart'; class ProductTitleCard extends StatelessWidget { const ProductTitleCard( @@ -69,7 +69,7 @@ class ProductTitleCard extends StatelessWidget { verticalOffset: imageSize.width / 2, preferBelow: true, ), - child: ProductPicture( + child: ProductPicture.fromProduct( product: product, imageField: ImageField.FRONT, fallbackUrl: product.imageFrontUrl, 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 e1cd1daaffbb..c6f04cd98232 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 @@ -17,9 +17,63 @@ import 'package:smooth_app/themes/smooth_theme_colors.dart'; import 'package:smooth_app/themes/theme_provider.dart'; class ProductPicture extends StatefulWidget { - ProductPicture({ + ProductPicture.fromProduct({ + required Product product, + required ImageField imageField, + required Size size, + String? fallbackUrl, + VoidCallback? onTap, + String? heroTag, + bool? showObsoleteIcon, + BorderRadius? borderRadius, + double? imageFoundBorder, + double? imageNotFoundBorder, + TextStyle? errorTextStyle, + }) : this._( + transientFile: null, + product: product, + imageField: imageField, + size: size, + fallbackUrl: fallbackUrl, + heroTag: heroTag, + onTap: onTap, + borderRadius: borderRadius, + imageFoundBorder: imageFoundBorder ?? 0.0, + imageNotFoundBorder: imageNotFoundBorder ?? 0.0, + errorTextStyle: errorTextStyle, + showObsoleteIcon: showObsoleteIcon ?? false, + ); + + ProductPicture.fromTransientFile({ + required TransientFile transientFile, + required Size size, + String? fallbackUrl, + VoidCallback? onTap, + String? heroTag, + bool? showObsoleteIcon, + BorderRadius? borderRadius, + double? imageFoundBorder, + double? imageNotFoundBorder, + TextStyle? errorTextStyle, + }) : this._( + transientFile: transientFile, + product: null, + imageField: null, + size: size, + fallbackUrl: fallbackUrl, + heroTag: heroTag, + onTap: onTap, + borderRadius: borderRadius, + imageFoundBorder: imageFoundBorder ?? 0.0, + imageNotFoundBorder: imageNotFoundBorder ?? 0.0, + errorTextStyle: errorTextStyle, + showObsoleteIcon: showObsoleteIcon ?? false, + ); + + ProductPicture._({ required this.product, required this.imageField, + required this.transientFile, required this.size, this.fallbackUrl, this.heroTag, @@ -35,8 +89,9 @@ class ProductPicture extends StatefulWidget { assert(heroTag == null || heroTag.isNotEmpty), assert(size.width >= 0.0 && size.height >= 0.0); - final Product product; - final ImageField imageField; + final Product? product; + final ImageField? imageField; + final TransientFile? transientFile; final Size size; final String? fallbackUrl; final VoidCallback? onTap; @@ -63,8 +118,9 @@ class _ProductPictureState extends State { @override Widget build(BuildContext context) { - final (ImageProvider, bool)? imageProvider = _getImageProvider( + final (ImageProvider?, bool)? imageProvider = _getImageProvider( widget.product, + widget.transientFile, ); final Widget? inkWell = widget.onTap != null @@ -91,9 +147,9 @@ class _ProductPictureState extends State { border: widget.imageNotFoundBorder, child: inkWell, ); - } else if (imageProvider != null) { + } else if (imageProvider?.$1 != null) { child = _ProductPictureWithImageProvider( - imageProvider: imageProvider.$1, + imageProvider: imageProvider!.$1!, outdated: imageProvider.$2, heroTag: widget.heroTag, size: widget.size, @@ -143,16 +199,24 @@ class _ProductPictureState extends State { /// 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, + (ImageProvider?, bool)? _getImageProvider( + Product? product, + TransientFile? transientFile, + ) { + if (transientFile != null) { + return (transientFile.getImageProvider(), transientFile.expired); + } + + final TransientFile productTransientFile = TransientFile.fromProduct( + product!, + widget.imageField!, ProductQuery.getLanguage(), ); - final ImageProvider? imageProvider = transientFile.getImageProvider(); + final ImageProvider? imageProvider = + productTransientFile.getImageProvider(); if (imageProvider != null) { - return (imageProvider, transientFile.expired); + return (imageProvider, productTransientFile.expired); } else if (widget.fallbackUrl?.isNotEmpty == true) { return (NetworkImage(widget.fallbackUrl!), false); } else { diff --git a/packages/smooth_app/lib/generic_lib/widgets/language_selector.dart b/packages/smooth_app/lib/generic_lib/widgets/language_selector.dart index 96746a30e09d..a8196c95f40a 100644 --- a/packages/smooth_app/lib/generic_lib/widgets/language_selector.dart +++ b/packages/smooth_app/lib/generic_lib/widgets/language_selector.dart @@ -39,7 +39,7 @@ class LanguageSelector extends StatelessWidget { /// Product from which we can extract the languages that matter. final Product? product; - static const Languages _languages = Languages(); + static final Languages _languages = Languages(); @override Widget build(BuildContext context) { @@ -61,7 +61,7 @@ class LanguageSelector extends StatelessWidget { type: MaterialType.transparency, child: InkWell( onTap: () async { - final OpenFoodFactsLanguage? language = await _openLanguageSelector( + final OpenFoodFactsLanguage? language = await openLanguageSelector( context, selectedLanguages: selectedLanguages, languagePriority: languagePriority, @@ -117,7 +117,8 @@ class LanguageSelector extends StatelessWidget { /// Returns the language selected by the user. /// /// [selectedLanguages] have a specific "more important" display. - Future _openLanguageSelector( + // TODO(g123k): Improve the language selector to usable without the Widget + static Future openLanguageSelector( final BuildContext context, { final Iterable? selectedLanguages, required final LanguagePriority languagePriority, @@ -212,7 +213,7 @@ class LanguageSelector extends StatelessWidget { ); } - String _getCompleteName( + static String _getCompleteName( final OpenFoodFactsLanguage language, ) { final String nameInLanguage = _languages.getNameInLanguage(language); diff --git a/packages/smooth_app/lib/helpers/border_radius_helper.dart b/packages/smooth_app/lib/helpers/border_radius_helper.dart new file mode 100644 index 000000000000..7972305a8815 --- /dev/null +++ b/packages/smooth_app/lib/helpers/border_radius_helper.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; + +class BorderRadiusHelper { + BorderRadiusHelper._(); + + /// [InkWell] only supports [BorderRadius]. + /// This helps to create a [BorderRadius] from a [BorderRadiusDirectional]. + static BorderRadius fromDirectional({ + required BuildContext context, + Radius? topStart, + Radius? topEnd, + Radius? bottomStart, + Radius? bottomEnd, + }) { + final TextDirection textDirection = Directionality.of(context); + + return BorderRadius.only( + topLeft: textDirection == TextDirection.ltr + ? topStart ?? Radius.zero + : topEnd ?? Radius.zero, + topRight: textDirection == TextDirection.ltr + ? topEnd ?? Radius.zero + : topStart ?? Radius.zero, + bottomLeft: textDirection == TextDirection.ltr + ? bottomStart ?? Radius.zero + : bottomEnd ?? Radius.zero, + bottomRight: textDirection == TextDirection.ltr + ? bottomEnd ?? Radius.zero + : bottomStart ?? Radius.zero, + ); + } +} diff --git a/packages/smooth_app/lib/helpers/ui_helpers.dart b/packages/smooth_app/lib/helpers/ui_helpers.dart index 72d85bdc5769..7ffeb38743a9 100644 --- a/packages/smooth_app/lib/helpers/ui_helpers.dart +++ b/packages/smooth_app/lib/helpers/ui_helpers.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; @@ -45,3 +47,44 @@ Color? getTextColorFromKnowledgePanelElementEvaluation(Evaluation evaluation) { return DARK_GREEN_COLOR; } } + +extension BoxConstraintsExtension on BoxConstraints { + double get minSide => math.min(maxWidth, maxHeight); +} + +extension StatelessWidgetExtension on StatelessWidget { + void onNextFrame(VoidCallback callback) { + WidgetsBinding.instance.addPostFrameCallback((_) { + callback(); + }); + } +} + +extension StateExtension on State { + void onNextFrame(VoidCallback callback) { + WidgetsBinding.instance.addPostFrameCallback((_) { + callback(); + }); + } +} + +extension ScrollMetricsExtension on ScrollMetrics { + double get page => extentBefore / extentInside; + + bool get hasScrolled => extentBefore % extentInside != 0; +} + +extension ScrollControllerExtension on ScrollController { + void jumpBy(double offset) => jumpTo(position.pixels + offset); + + void animateBy( + double offset, { + required Duration duration, + required Curve curve, + }) => + animateTo( + position.pixels + offset, + duration: duration, + curve: curve, + ); +} diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 48672dec1b9b..8af085fad699 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -3216,5 +3216,9 @@ "product_page_action_bar_item_disable": "Disable action", "@product_page_action_bar_item_disable": { "description": "Accessibility label to disable action (= make it invisible)" + }, + "product_add_a_language": "Add a language", + "@product_add_a_language": { + "description": "Button to add a language (eg: for photos) to a product" } } diff --git a/packages/smooth_app/lib/pages/image_crop_page.dart b/packages/smooth_app/lib/pages/image_crop_page.dart index ffdf8afb8f94..aed3920e65b5 100644 --- a/packages/smooth_app/lib/pages/image_crop_page.dart +++ b/packages/smooth_app/lib/pages/image_crop_page.dart @@ -22,6 +22,9 @@ import 'package:smooth_app/pages/crop_helper.dart'; import 'package:smooth_app/pages/crop_page.dart'; import 'package:smooth_app/pages/crop_parameters.dart'; import 'package:smooth_app/pages/product_crop_helper.dart'; +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'; /// Safely picks an image file from gallery or camera, regarding access denied. Future pickImageFile( @@ -130,76 +133,79 @@ class _ImageSourcePickerState extends State<_ImageSourcePicker> { final AppLocalizations appLocalizations = AppLocalizations.of(context); final Color primaryColor = Theme.of(context).primaryColor; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: BALANCED_SPACE), - child: Row( - children: [ - Expanded( - flex: 5, - child: _ImageSourceButton( - semanticsOrder: 2.0, - onPressed: () => _selectSource(UserPictureSource.CAMERA), - label: Text( - appLocalizations.settings_app_camera, - textAlign: TextAlign.center, + return SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: BALANCED_SPACE), + child: Row( + children: [ + Expanded( + flex: 5, + child: _ImageSourceButton( + semanticsOrder: 2.0, + onPressed: () => _selectSource(UserPictureSource.CAMERA), + label: Text( + appLocalizations.settings_app_camera, + textAlign: TextAlign.center, + ), + icon: const Icon(Icons.camera_alt, size: 30.0), ), - icon: const Icon(Icons.camera_alt, size: 30.0), ), - ), - const Spacer(), - Expanded( - flex: 5, - child: _ImageSourceButton( - onPressed: () => _selectSource(UserPictureSource.GALLERY), - semanticsOrder: 3.0, - label: Text( - appLocalizations.gallery_source_label, - textAlign: TextAlign.center, + const Spacer(), + Expanded( + flex: 5, + child: _ImageSourceButton( + onPressed: () => _selectSource(UserPictureSource.GALLERY), + semanticsOrder: 3.0, + label: Text( + appLocalizations.gallery_source_label, + textAlign: TextAlign.center, + ), + icon: const Icon(Icons.image, size: 30.0), ), - icon: const Icon(Icons.image, size: 30.0), ), - ), - ], + ], + ), ), ), - ), - const SizedBox(height: VERY_LARGE_SPACE), - Semantics( - sortKey: const OrdinalSortKey(4.0), - value: appLocalizations.user_picture_source_remember, - checked: rememberChoice, - excludeSemantics: true, - child: InkWell( - onTap: () => setState(() => rememberChoice = !rememberChoice), - borderRadius: ANGULAR_BORDER_RADIUS, - splashColor: primaryColor.withOpacity(0.2), - child: Row( - children: [ - IgnorePointer( - child: Checkbox.adaptive( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(6.0)), - ), - activeColor: Theme.of(context).primaryColor, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: rememberChoice, - onChanged: (final bool? value) => setState( - () => rememberChoice = value ?? false, + const SizedBox(height: VERY_LARGE_SPACE), + Semantics( + sortKey: const OrdinalSortKey(4.0), + value: appLocalizations.user_picture_source_remember, + checked: rememberChoice, + excludeSemantics: true, + child: InkWell( + onTap: () => setState(() => rememberChoice = !rememberChoice), + borderRadius: ANGULAR_BORDER_RADIUS, + splashColor: primaryColor.withOpacity(0.2), + child: Row( + children: [ + IgnorePointer( + child: Checkbox.adaptive( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6.0)), + ), + activeColor: Theme.of(context).primaryColor, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: rememberChoice, + onChanged: (final bool? value) => setState( + () => rememberChoice = value ?? false, + ), ), ), - ), - Expanded( - child: Text(appLocalizations.user_picture_source_remember), - ) - ], + Expanded( + child: Text(appLocalizations.user_picture_source_remember), + ) + ], + ), ), ), - ), - ], + ], + ), ); } @@ -234,7 +240,13 @@ class _ImageSourceButton extends StatelessWidget { onPressed: onPressed, style: ButtonStyle( side: WidgetStatePropertyAll( - BorderSide(color: primaryColor), + BorderSide( + color: context.lightTheme() + ? primaryColor + : context + .extension() + .primaryLight, + ), ), padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(vertical: LARGE_SPACE), diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_languages_list.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_languages_list.dart index 75c02404130e..23f16e6c6be7 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_languages_list.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_languages_list.dart @@ -4,7 +4,13 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:smooth_app/helpers/string_extension.dart'; class Languages { - const Languages(); + factory Languages() { + return _instance ??= const Languages._(); + } + + const Languages._(); + + static Languages? _instance; static const LocalizationsDelegate _delegate = GlobalMaterialLocalizations.delegate; diff --git a/packages/smooth_app/lib/pages/product/edit_product_page.dart b/packages/smooth_app/lib/pages/product/edit_product_page.dart index 90d042ebde48..b79118501e93 100644 --- a/packages/smooth_app/lib/pages/product/edit_product_page.dart +++ b/packages/smooth_app/lib/pages/product/edit_product_page.dart @@ -19,9 +19,9 @@ import 'package:smooth_app/pages/prices/price_meta_product.dart'; import 'package:smooth_app/pages/prices/product_price_add_page.dart'; import 'package:smooth_app/pages/product/add_other_details_page.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_view.dart'; import 'package:smooth_app/pages/product/nutrition_page_loaded.dart'; import 'package:smooth_app/pages/product/product_field_editor.dart'; -import 'package:smooth_app/pages/product/product_image_gallery_view.dart'; import 'package:smooth_app/pages/product/simple_input_page.dart'; import 'package:smooth_app/pages/product/simple_input_page_helpers.dart'; import 'package:smooth_app/resources/app_icons.dart' as icons; 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 new file mode 100644 index 000000000000..32515b2a19e7 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_photo_row.dart @@ -0,0 +1,222 @@ +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:smooth_app/cards/product_cards/smooth_product_image.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/product_image_swipeable_view.dart'; +import 'package:smooth_app/resources/app_animations.dart'; +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'; + +class PhotoRow extends StatelessWidget { + const PhotoRow({ + required this.position, + required this.product, + required this.language, + required this.imageField, + }); + + static double itemHeight = 55.0; + + final int position; + final Product product; + final OpenFoodFactsLanguage language; + final ImageField imageField; + + @override + Widget build(BuildContext context) { + final TransientFile transientFile = _getTransientFile(imageField); + + final bool expired = transientFile.expired; + + final AppLocalizations appLocalizations = AppLocalizations.of(context); + final String label = imageField.getProductImageTitle(appLocalizations); + + final SmoothColorsThemeExtension extension = + context.extension(); + + return Semantics( + image: true, + button: true, + label: expired + ? appLocalizations.product_image_outdated_accessibility_label(label) + : label, + excludeSemantics: true, + 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: 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: Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints box) { + return ProductPicture.fromTransientFile( + transientFile: transientFile, + 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), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Future _openImage({ + required BuildContext context, + required int initialImageIndex, + }) async => + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ProductImageSwipeableView( + initialImageIndex: initialImageIndex, + product: product, + isLoggedInMandatory: true, + initialLanguage: language, + ), + ), + ); + + TransientFile _getTransientFile( + final ImageField imageField, + ) => + TransientFile.fromProduct( + product, + imageField, + 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/gallery_view/product_image_gallery_tabs.dart b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart new file mode 100644 index 000000000000..095bfe538d5a --- /dev/null +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_tabs.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart' hide Listener; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/database/dao_string_list.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/widgets/language_priority.dart'; +import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; +import 'package:smooth_app/helpers/border_radius_helper.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/helpers/ui_helpers.dart'; +import 'package:smooth_app/pages/preferences/user_preferences_languages_list.dart'; +import 'package:smooth_app/query/product_query.dart'; +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/themes/theme_provider.dart'; +import 'package:smooth_app/widgets/smooth_tabbar.dart'; + +class ProductImageGalleryTabBar extends StatefulWidget + implements PreferredSizeWidget { + const ProductImageGalleryTabBar({ + required this.padding, + required this.onTabChanged, + }); + + final EdgeInsetsGeometry padding; + final void Function(OpenFoodFactsLanguage) onTabChanged; + + @override + State createState() => + _ProductImageGalleryTabBarState(); + + @override + Size get preferredSize => const Size( + double.infinity, + SmoothTabBar.TAB_BAR_HEIGHT + BALANCED_SPACE, + ); +} + +class _ProductImageGalleryTabBarState extends State + with TickerProviderStateMixin { + late final _ImageGalleryLanguagesProvider _provider; + late TabController? _tabController; + + @override + void initState() { + super.initState(); + _provider = _ImageGalleryLanguagesProvider()..addListener(_updateListener); + _tabController = TabController(length: 0, vsync: this); + } + + @override + Widget build(BuildContext context) { + return SizedBox.fromSize( + size: widget.preferredSize, + child: Padding( + padding: const EdgeInsetsDirectional.only(top: BALANCED_SPACE), + child: ChangeNotifierProvider<_ImageGalleryLanguagesProvider>( + create: (BuildContext context) { + _provider.attachProduct(context.read()); + return _provider; + }, + child: Consumer<_ImageGalleryLanguagesProvider>( + builder: ( + final BuildContext context, + final _ImageGalleryLanguagesProvider provider, + _, + ) { + if (provider.value.languages == null) { + return const Center( + child: CircularProgressIndicator.adaptive()); + } + + /// We need a Stack to have to tab bar shadow below the button + return Stack( + children: [ + PositionedDirectional( + top: 0.0, + start: 0.0, + bottom: 0.0, + end: 40.0, + child: SmoothTabBar( + tabController: _tabController!, + items: provider.value.languages!.map( + (final OpenFoodFactsLanguage language) => + SmoothTabBarItem( + label: Languages().getNameInLanguage(language), + value: language, + ), + ), + onTabChanged: (final OpenFoodFactsLanguage value) { + widget.onTabChanged.call(value); + }, + padding: widget.padding.add( + const EdgeInsetsDirectional.only( + end: 20.0, + ), + ), + ), + ), + const PositionedDirectional( + top: 0.0, + end: 0.0, + bottom: 0.0, + child: _ImageGalleryAddLanguageButton(), + ), + ], + ); + }, + ), + ), + ), + ); + } + + void _updateListener() { + if (_provider.value.languages != null) { + final int initialIndex = _tabController?.index ?? -1; + final int newIndex = _provider.value.selectedLanguage != null + ? _provider.value.languages! + .indexOf(_provider.value.selectedLanguage!) + : initialIndex; + + if (_tabController?.length != _provider.value.languages!.length) { + _tabController = TabController( + length: _provider.value.languages!.length, + vsync: this, + initialIndex: newIndex >= 0 ? newIndex : 0, + ); + } else if (newIndex >= 0 && _tabController!.index != newIndex) { + onNextFrame(() { + _tabController!.animateTo(newIndex); + _provider.newLanguageSuccessfullyChanged(); + }); + } + + if (newIndex >= 0 && initialIndex != newIndex) { + widget.onTabChanged.call(_provider.value.selectedLanguage!); + } + } + } +} + +class _ImageGalleryAddLanguageButton extends StatelessWidget { + const _ImageGalleryAddLanguageButton(); + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension theme = + context.extension(); + + final BorderRadius borderRadius = BorderRadiusHelper.fromDirectional( + context: context, + topStart: const Radius.circular(10.0), + ); + + final String label = AppLocalizations.of(context).product_add_a_language; + + return Semantics( + label: label, + button: true, + excludeSemantics: true, + child: Tooltip( + message: label, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: + context.lightTheme() ? theme.primaryDark : theme.primaryNormal, + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: borderRadius, + onTap: () => _addLanguage(context), + child: const Padding( + padding: EdgeInsetsDirectional.only( + start: LARGE_SPACE, + end: MEDIUM_SPACE, + ), + child: Add( + color: Colors.white, + ), + ), + ), + ), + ), + ), + ); + } + + Future _addLanguage(BuildContext context) async { + // TODO(g123k): Improve the language selector + final DaoStringList daoStringList = + DaoStringList(context.read()); + + final List? selectedLanguages = + context.read<_ImageGalleryLanguagesProvider>().value.languages; + + final LanguagePriority languagePriority = LanguagePriority( + product: context.read(), + selectedLanguages: selectedLanguages, + daoStringList: daoStringList, + ); + + final OpenFoodFactsLanguage? language = + await LanguageSelector.openLanguageSelector( + context, + selectedLanguages: selectedLanguages, + languagePriority: languagePriority, + ); + + if (language != null && context.mounted) { + context.read<_ImageGalleryLanguagesProvider>().addLanguage(language); + } + } +} + +class _ImageGalleryLanguagesProvider + extends ValueNotifier<_ImageGalleryLanguagesState> { + _ImageGalleryLanguagesProvider() + : super(const _ImageGalleryLanguagesState.empty()); + + late Product product; + + void attachProduct(final Product product) { + this.product = product; + refreshLanguages(); + } + + void refreshLanguages() { + final List imageLanguages = { + ...getProductImageLanguages(product, ImageField.FRONT), + ...getProductImageLanguages(product, ImageField.INGREDIENTS), + ...getProductImageLanguages(product, ImageField.NUTRITION), + ...getProductImageLanguages(product, ImageField.PACKAGING), + }.toList(growable: false) + ..sort((final OpenFoodFactsLanguage a, final OpenFoodFactsLanguage b) => + a.name.compareTo(b.name)); + + /// The main language is always the first, then the user one + final OpenFoodFactsLanguage userLanguage = ProductQuery.getLanguage(); + final OpenFoodFactsLanguage? mainLanguage = product.lang; + + final List languages = []; + + if (mainLanguage != null) { + languages.add(mainLanguage); + } + + if (mainLanguage != userLanguage) { + languages.add(userLanguage); + } + + for (final OpenFoodFactsLanguage language in imageLanguages) { + if (language != mainLanguage && language != userLanguage) { + languages.add(language); + } + } + + value = _ImageGalleryLanguagesState( + languages: languages, + selectedLanguage: mainLanguage ?? userLanguage, + ); + } + + void addLanguage(OpenFoodFactsLanguage language) { + if (value.languages == null) { + throw Exception('Languages are not loaded'); + } + + if (value.languages!.contains(language)) { + value = _ImageGalleryLanguagesState( + languages: value.languages, + selectedLanguage: language, + ); + } else { + value = _ImageGalleryLanguagesState( + languages: [...value.languages!, language], + selectedLanguage: language, + hasNewLanguage: true, + ); + } + } + + /// Generate a state with [hasNewLanguage] set to false + void newLanguageSuccessfullyChanged() { + value = _ImageGalleryLanguagesState( + languages: value.languages, + selectedLanguage: value.selectedLanguage, + hasNewLanguage: false, + ); + } +} + +class _ImageGalleryLanguagesState { + _ImageGalleryLanguagesState({ + required this.languages, + required this.selectedLanguage, + this.hasNewLanguage = false, + }) : assert( + selectedLanguage == null || languages!.contains(selectedLanguage)); + + const _ImageGalleryLanguagesState.empty() + : languages = null, + selectedLanguage = null, + hasNewLanguage = false; + + final List? languages; + final OpenFoodFactsLanguage? selectedLanguage; + final bool hasNewLanguage; +} diff --git a/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart new file mode 100644 index 000000000000..9e1519479265 --- /dev/null +++ b/packages/smooth_app/lib/pages/product/gallery_view/product_image_gallery_view.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/data_models/up_to_date_mixin.dart'; +import 'package:smooth_app/database/local_database.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/smooth_app_logo.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; +import 'package:smooth_app/helpers/border_radius_helper.dart'; +import 'package:smooth_app/helpers/image_field_extension.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/helpers/ui_helpers.dart'; +import 'package:smooth_app/pages/image/product_image_gallery_other_view.dart'; +import 'package:smooth_app/pages/image_crop_page.dart'; +import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_photo_row.dart'; +import 'package:smooth_app/pages/product/gallery_view/product_image_gallery_tabs.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'; +import 'package:smooth_app/widgets/slivers.dart'; +import 'package:smooth_app/widgets/smooth_scaffold.dart'; + +/// Display of the main 4 pictures of a product, with edit options. +class ProductImageGalleryView extends StatefulWidget { + const ProductImageGalleryView({ + required this.product, + }); + + final Product product; + + @override + State createState() => + _ProductImageGalleryViewState(); +} + +class _ProductImageGalleryViewState extends State + with UpToDateMixin { + late OpenFoodFactsLanguage _language; + late final List _mainImageFields; + bool _clickedOtherPictureButton = false; + + @override + void initState() { + super.initState(); + initUpToDate(widget.product, context.read()); + _language = ProductQuery.getLanguage(); + _mainImageFields = ImageFieldSmoothieExtension.getOrderedMainImageFields( + widget.product.productType, + ); + } + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + context.watch(); + refreshUpToDate(); + + return Provider.value( + value: upToDateProduct, + child: SmoothSharedAnimationController( + child: SmoothScaffold( + appBar: buildEditProductAppBar( + context: context, + title: appLocalizations.edit_product_form_item_photos_title, + product: upToDateProduct, + bottom: ProductImageGalleryTabBar( + onTabChanged: (final OpenFoodFactsLanguage language) => + onNextFrame( + () => setState(() => _language = language), + ), + padding: const EdgeInsetsDirectional.only(start: 55.0), + ), + ), + body: Stack( + children: [ + Positioned.fill( + child: Column( + children: [ + Expanded( + child: RefreshIndicator( + onRefresh: () async => + ProductRefresher().fetchAndRefresh( + barcode: barcode, + context: context, + ), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsetsDirectional.only( + top: VERY_SMALL_SPACE, + ), + sliver: SliverGrid( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCountAndFixedHeight( + crossAxisCount: 2, + height: (MediaQuery.sizeOf(context).width / + 2.15) + + PhotoRow.itemHeight, + ), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + 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( + padding: const EdgeInsetsDirectional.symmetric( + vertical: MEDIUM_SPACE, + horizontal: SMALL_SPACE, + ), + sliver: SliverToBoxAdapter( + child: Text( + appLocalizations.more_photos, + style: + Theme.of(context).textTheme.displayMedium, + ), + ), + ), + if (_shouldDisplayRawGallery()) + ProductImageGalleryOtherView( + product: upToDateProduct) + else + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(SMALL_SPACE), + child: SmoothLargeButtonWithIcon( + text: + appLocalizations.view_more_photo_button, + icon: Icons.photo_camera_rounded, + onPressed: () => setState( + () => _clickedOtherPictureButton = true, + ), + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: + MediaQuery.viewPaddingOf(context).bottom + + (VERY_LARGE_SPACE * 2.5), + ), + ) + ], + ), + ), + ), + ], + ), + ), + Positioned.directional( + textDirection: Directionality.of(context), + bottom: 0.0, + end: 0.0, + child: const _ProductImageGalleryFooterButton(), + ), + ], + ), + persistentFooterAlignment: AlignmentDirectional.bottomEnd, + ), + ), + ); + } + + bool _shouldDisplayRawGallery() => + _clickedOtherPictureButton || + (upToDateProduct.getRawImages()?.isNotEmpty == true); +} + +class _ProductImageGalleryFooterButton extends StatelessWidget { + const _ProductImageGalleryFooterButton(); + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + final SmoothColorsThemeExtension theme = + context.extension(); + + final BorderRadius borderRadius = BorderRadiusHelper.fromDirectional( + context: context, + topStart: ROUNDED_RADIUS, + ); + + return Semantics( + button: true, + label: appLocalizations.add_photo_button_label, + excludeSemantics: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: + context.lightTheme() ? theme.primaryMedium : theme.primaryNormal, + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity(0.3), + blurRadius: 5.0, + spreadRadius: 1.0, + offset: Offset.zero, + ), + ], + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: borderRadius, + onTap: () async { + final Product product = context.read(); + + AnalyticsHelper.trackProductEdit( + AnalyticsEditEvents.photos, + product, + true, + ); + await confirmAndUploadNewPicture( + context, + imageField: ImageField.OTHER, + barcode: product.barcode!, + language: ProductQuery.getLanguage(), + isLoggedInMandatory: true, + productType: product.productType, + ); + }, + child: Padding( + padding: EdgeInsetsDirectional.only( + top: LARGE_SPACE, + start: LARGE_SPACE, + end: LARGE_SPACE, + bottom: + MediaQuery.viewPaddingOf(context).bottom + VERY_SMALL_SPACE, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const icons.Add( + color: Colors.black, + ), + const SizedBox(width: BALANCED_SPACE), + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 2.0), + child: Text( + appLocalizations.add_photo_button_label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15.0, + color: Colors.black, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} 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 deleted file mode 100644 index 84751b5d0d88..000000000000 --- a/packages/smooth_app/lib/pages/product/product_image_gallery_view.dart +++ /dev/null @@ -1,401 +0,0 @@ -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/language_selector.dart'; -import 'package:smooth_app/generic_lib/widgets/smooth_app_logo.dart'; -import 'package:smooth_app/helpers/analytics_helper.dart'; -import 'package:smooth_app/helpers/image_field_extension.dart'; -import 'package:smooth_app/helpers/product_cards_helper.dart'; -import 'package:smooth_app/pages/image/product_image_gallery_other_view.dart'; -import 'package:smooth_app/pages/image/product_image_helper.dart'; -import 'package:smooth_app/pages/image_crop_page.dart'; -import 'package:smooth_app/pages/product/common/product_refresher.dart'; -import 'package:smooth_app/pages/product/product_image_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'; -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'; - -/// Display of the main 4 pictures of a product, with edit options. -class ProductImageGalleryView extends StatefulWidget { - const ProductImageGalleryView({ - required this.product, - }); - - final Product product; - - @override - State createState() => - _ProductImageGalleryViewState(); -} - -class _ProductImageGalleryViewState extends State - with UpToDateMixin { - late OpenFoodFactsLanguage _language; - late final List _mainImageFields; - bool _clickedOtherPictureButton = false; - - @override - void initState() { - super.initState(); - initUpToDate(widget.product, context.read()); - _language = ProductQuery.getLanguage(); - _mainImageFields = ImageFieldSmoothieExtension.getOrderedMainImageFields( - widget.product.productType, - ); - } - - @override - Widget build(BuildContext context) { - final AppLocalizations appLocalizations = AppLocalizations.of(context); - context.watch(); - refreshUpToDate(); - return SmoothSharedAnimationController( - child: SmoothScaffold( - appBar: buildEditProductAppBar( - context: context, - title: appLocalizations.edit_product_form_item_photos_title, - product: upToDateProduct, - 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: [ - Expanded( - child: RefreshIndicator( - onRefresh: () async => ProductRefresher().fetchAndRefresh( - barcode: barcode, - context: context, - ), - child: CustomScrollView( - slivers: [ - SliverGrid( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCountAndFixedHeight( - crossAxisCount: 2, - height: (MediaQuery.sizeOf(context).width / 2.15) + - _PhotoRow.itemHeight, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - 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( - padding: const EdgeInsetsDirectional.symmetric( - vertical: MEDIUM_SPACE, - horizontal: SMALL_SPACE, - ), - sliver: SliverToBoxAdapter( - child: Text( - appLocalizations.more_photos, - style: Theme.of(context).textTheme.displayMedium, - ), - ), - ), - if (_shouldDisplayRawGallery()) - ProductImageGalleryOtherView(product: upToDateProduct) - else - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(SMALL_SPACE), - child: SmoothLargeButtonWithIcon( - text: appLocalizations.view_more_photo_button, - icon: Icons.photo_camera_rounded, - onPressed: () => setState( - () => _clickedOtherPictureButton = true, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - bool _shouldDisplayRawGallery() => - _clickedOtherPictureButton || - (upToDateProduct.getRawImages()?.isNotEmpty == true); -} - -class _PhotoRow extends StatelessWidget { - const _PhotoRow({ - required this.position, - required this.product, - required this.language, - required this.imageField, - }); - - static double itemHeight = 55.0; - - final int position; - final Product product; - final OpenFoodFactsLanguage language; - final ImageField imageField; - - @override - Widget build(BuildContext context) { - final TransientFile transientFile = _getTransientFile(imageField); - - final bool expired = transientFile.expired; - - final AppLocalizations appLocalizations = AppLocalizations.of(context); - final String label = imageField.getProductImageTitle(appLocalizations); - - final SmoothColorsThemeExtension extension = - context.extension(); - - return Semantics( - image: true, - button: true, - label: expired - ? appLocalizations.product_image_outdated_accessibility_label(label) - : label, - excludeSemantics: true, - 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: 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: 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), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - Future _openImage({ - required BuildContext context, - required int initialImageIndex, - }) async => - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProductImageSwipeableView( - initialImageIndex: initialImageIndex, - product: product, - isLoggedInMandatory: true, - initialLanguage: language, - ), - ), - ); - - TransientFile _getTransientFile( - final ImageField imageField, - ) => - TransientFile.fromProduct( - product, - imageField, - 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/query/product_query.dart b/packages/smooth_app/lib/query/product_query.dart index 8386ea5f22af..c0cf2eaf8cc5 100644 --- a/packages/smooth_app/lib/query/product_query.dart +++ b/packages/smooth_app/lib/query/product_query.dart @@ -13,6 +13,8 @@ import 'package:uuid/uuid.dart'; // ignore: avoid_classes_with_only_static_members abstract class ProductQuery { + const ProductQuery._(); + static const ProductQueryVersion productQueryVersion = ProductQueryVersion.v3; static late OpenFoodFactsCountry _country; diff --git a/packages/smooth_app/lib/widgets/smooth_scaffold.dart b/packages/smooth_app/lib/widgets/smooth_scaffold.dart index 2c75e2d2e08b..a48833257097 100644 --- a/packages/smooth_app/lib/widgets/smooth_scaffold.dart +++ b/packages/smooth_app/lib/widgets/smooth_scaffold.dart @@ -20,6 +20,7 @@ class SmoothScaffold extends Scaffold { super.floatingActionButtonLocation, super.floatingActionButtonAnimator, super.persistentFooterButtons, + super.persistentFooterAlignment, super.drawer, super.onDrawerChanged, super.endDrawer, diff --git a/packages/smooth_app/lib/widgets/smooth_tabbar.dart b/packages/smooth_app/lib/widgets/smooth_tabbar.dart new file mode 100644 index 000000000000..5b35663c355a --- /dev/null +++ b/packages/smooth_app/lib/widgets/smooth_tabbar.dart @@ -0,0 +1,223 @@ +import 'dart:ui' as ui; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:smooth_app/helpers/num_utils.dart'; +import 'package:smooth_app/helpers/ui_helpers.dart'; +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 SmoothTabBar extends StatefulWidget { + const SmoothTabBar({ + required this.tabController, + required this.items, + required this.onTabChanged, + this.padding, + super.key, + }) : assert(items.length > 0); + + static const double TAB_BAR_HEIGHT = 46.0; + + final TabController tabController; + final Iterable> items; + final Function(T) onTabChanged; + final EdgeInsetsGeometry? padding; + + @override + State> createState() => _SmoothTabBarState(); +} + +class _SmoothTabBarState extends State> { + double _horizontalProgress = 0.0; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension theme = + context.extension(); + final bool lightTheme = context.lightTheme(); + + return CustomPaint( + painter: _ProductHeaderTabBarPainter( + progress: _horizontalProgress, + primaryColor: lightTheme ? theme.primaryLight : theme.primaryDark, + bottomSeparatorColor: theme.primaryDark, + backgroundColor: AppBarTheme.of(context).backgroundColor ?? + Theme.of(context).scaffoldBackgroundColor, + ), + child: SizedBox( + height: SmoothTabBar.TAB_BAR_HEIGHT, + child: NotificationListener( + onNotification: (ScrollNotification notif) { + onNextFrame(() { + setState(() { + _horizontalProgress = + notif.metrics.pixels / notif.metrics.maxScrollExtent; + }); + }); + + return false; + }, + child: TabBar( + controller: widget.tabController, + tabs: widget.items + .mapIndexed( + (int position, SmoothTabBarItem item) => _SmoothTab( + item: item, + selected: widget.tabController.index == position, + ), + ) + .toList(growable: false), + isScrollable: true, + padding: widget.padding, + labelPadding: EdgeInsets.zero, + tabAlignment: TabAlignment.start, + overlayColor: WidgetStatePropertyAll( + theme.primaryLight, + ), + splashBorderRadius: const BorderRadius.vertical( + top: Radius.circular(5.0), + ), + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15.0, + color: theme.primaryBlack, + ), + unselectedLabelStyle: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15.0, + color: lightTheme ? theme.primarySemiDark : theme.primaryNormal, + ), + indicator: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.primaryDark, + width: 3.0, + ), + ), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(5.0), + ), + color: theme.primaryLight, + ), + onTap: (int position) => widget.onTabChanged.call( + widget.items.elementAt(position).value, + ), + ), + ), + ), + ); + } +} + +class SmoothTabBarItem { + const SmoothTabBarItem({ + required this.label, + required this.value, + }) : assert(label.length > 0); + + final String label; + final T value; +} + +class _SmoothTab extends StatelessWidget { + const _SmoothTab({ + required this.item, + required this.selected, + }); + + final SmoothTabBarItem item; + final bool selected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Center( + child: Text(item.label), + ), + ); + } +} + +class _ProductHeaderTabBarPainter extends CustomPainter { + _ProductHeaderTabBarPainter({ + required this.progress, + required this.primaryColor, + required this.bottomSeparatorColor, + required this.backgroundColor, + }); + + final double progress; + final Color primaryColor; + final Color bottomSeparatorColor; + final Color backgroundColor; + final Paint _paint = Paint(); + + @override + void paint(Canvas canvas, Size size) { + final double gradientSize = size.width * 0.1; + + if (progress > 0.0) { + _paint.shader = ui.Gradient.linear( + Offset.zero, + Offset(gradientSize, 0.0), + [ + primaryColor.withOpacity( + progress.progressAndClamp(0.0, 0.3, 1.0), + ), + backgroundColor, + ], + ); + + canvas.drawRect( + Rect.fromLTWH( + 0, + 0, + gradientSize, + size.height, + ), + _paint, + ); + } + + if (progress < 1.0) { + _paint.shader = ui.Gradient.linear( + Offset(size.width - gradientSize, 0.0), + Offset(size.width, 0.0), + [ + backgroundColor, + primaryColor.withOpacity( + 1 - progress.progressAndClamp(0.7, 1.0, 1.0), + ), + ], + ); + + canvas.drawRect( + Rect.fromLTWH( + size.width - gradientSize, + 0, + size.width, + size.height, + ), + _paint, + ); + } + + _paint + ..shader = null + ..color = bottomSeparatorColor; + canvas.drawLine( + Offset(0, size.height - 1.0), + Offset(size.width, size.height - 1.0), + _paint, + ); + } + + @override + bool shouldRepaint(_ProductHeaderTabBarPainter oldDelegate) => + oldDelegate.progress != progress; + + @override + bool shouldRebuildSemantics(_ProductHeaderTabBarPainter oldDelegate) => true; +}