diff --git a/packages/smooth_app/assets/animations/off.riv b/packages/smooth_app/assets/animations/off.riv new file mode 100644 index 00000000000..56224533e7f Binary files /dev/null and b/packages/smooth_app/assets/animations/off.riv differ diff --git a/packages/smooth_app/lib/cards/product_cards/smooth_product_base_card.dart b/packages/smooth_app/lib/cards/product_cards/smooth_product_base_card.dart index 94aeaff7797..d80f0a34fe2 100644 --- a/packages/smooth_app/lib/cards/product_cards/smooth_product_base_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/smooth_product_base_card.dart @@ -68,19 +68,22 @@ class ProductCardCloseButton extends StatelessWidget { Widget build(BuildContext context) { final AppLocalizations appLocalizations = AppLocalizations.of(context); - return InkWell( - customBorder: const CircleBorder(), - onTap: () { - onRemove?.call(context); - SmoothHapticFeedback.lightNotification(); - }, - child: Tooltip( - message: appLocalizations.product_card_remove_product_tooltip, - child: Padding( - padding: const EdgeInsets.all(SMALL_SPACE), - child: Icon( - iconData, - size: DEFAULT_ICON_SIZE, + return Material( + type: MaterialType.transparency, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + onRemove?.call(context); + SmoothHapticFeedback.lightNotification(); + }, + child: Tooltip( + message: appLocalizations.product_card_remove_product_tooltip, + child: Padding( + padding: const EdgeInsets.all(SMALL_SPACE), + child: Icon( + iconData, + size: DEFAULT_ICON_SIZE, + ), ), ), ), diff --git a/packages/smooth_app/lib/cards/product_cards/smooth_product_card_loading.dart b/packages/smooth_app/lib/cards/product_cards/smooth_product_card_loading.dart index b9aa282b164..87d6b4fd580 100644 --- a/packages/smooth_app/lib/cards/product_cards/smooth_product_card_loading.dart +++ b/packages/smooth_app/lib/cards/product_cards/smooth_product_card_loading.dart @@ -1,39 +1,224 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:rive/rive.dart'; +import 'package:smooth_app/cards/product_cards/smooth_product_base_card.dart'; +import 'package:smooth_app/data_models/continuous_scan_model.dart'; +import 'package:smooth_app/generic_lib/buttons/smooth_simple_button.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/duration_constants.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; -class SmoothProductCardLoading extends StatelessWidget { - const SmoothProductCardLoading({required this.barcode}); +class SmoothProductCardLoading extends StatefulWidget { + const SmoothProductCardLoading({ + required this.barcode, + this.onRemoveProduct, + }); final String barcode; + final OnRemoveCallback? onRemoveProduct; + + @override + State createState() => + _SmoothProductCardLoadingState(); +} + +class _SmoothProductCardLoadingState extends State { + late Timer _timer; + _SmoothProductCardLoadingProgress _progress = + _SmoothProductCardLoadingProgress.initial; + + @override + void initState() { + super.initState(); + _timer = Timer(const Duration(seconds: 7), _onLongRequest); + } @override Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); final ThemeData themeData = Theme.of(context); - return Container( - decoration: BoxDecoration( - color: themeData.brightness == Brightness.light - ? Colors.white - : Colors.black, - borderRadius: ROUNDED_BORDER_RADIUS, - ), - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(barcode, style: Theme.of(context).textTheme.titleMedium), - ], - ), - const SizedBox( - height: 12.0, - ), - const CircularProgressIndicator.adaptive() - ], - ), + return DefaultTextStyle.merge( + textAlign: TextAlign.center, + style: const TextStyle(height: 1.4), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + children: [ + Positioned.fill( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: SMALL_SPACE, + horizontal: MEDIUM_SPACE, + ), + decoration: BoxDecoration( + color: themeData.brightness == Brightness.light + ? Colors.white + : Colors.black, + borderRadius: ROUNDED_BORDER_RADIUS, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + const Spacer(), + Text( + appLocalizations.scan_product_loading, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + ), + const Spacer(flex: 2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: SMALL_SPACE, + vertical: SMALL_SPACE, + ), + color: Colors.grey.withOpacity(0.2), + child: Text( + '<${widget.barcode}>', + style: const TextStyle( + letterSpacing: 6.0, + fontFeatures: [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + const Spacer(flex: 2), + AnimatedSwitcher( + duration: SmoothAnimationsDuration.long, + child: Text(_description(appLocalizations)), + transitionBuilder: + (Widget child, Animation animation) { + return FadeTransition( + opacity: Tween( + begin: 0.0, + end: 1.0, + ).animate(animation), + child: child, + ); + }, + ), + const Spacer(), + Expanded( + flex: 10, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: _progress == + _SmoothProductCardLoadingProgress.unresponsive + ? Center( + child: SmoothSimpleButton( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.restart_alt), + const SizedBox( + width: SMALL_SPACE, + ), + Text(appLocalizations + .scan_product_loading_restart_button) + ], + ), + onPressed: () { + AnalyticsHelper.trackEvent( + AnalyticsEvent.restartProductLoading, + barcode: widget.barcode, + ); + + final ContinuousScanModel model = + context.read(); + + model.retryBarcodeFetch(widget.barcode); + }, + ), + ) + : const RiveAnimation.asset( + 'assets/animations/off.riv', + artboard: 'Loading', + alignment: Alignment.topCenter, + fit: BoxFit.fitHeight, + ), + ), + ), + const Spacer(), + ], + ), + ), + ), + if (_progress != _SmoothProductCardLoadingProgress.initial) + Positioned.directional( + top: 0.0, + end: 0.0, + textDirection: Directionality.of(context), + child: Padding( + padding: EdgeInsetsDirectional.only( + top: constraints.maxHeight * 0.05, + end: constraints.maxWidth * 0.05, + ), + child: ProductCardCloseButton( + onRemove: (BuildContext context) { + AnalyticsHelper.trackEvent( + AnalyticsEvent.ignoreProductLoading, + barcode: widget.barcode, + ); + + widget.onRemoveProduct?.call(context); + }, + iconData: CupertinoIcons.clear_circled, + ), + ), + ), + ], + ); + }), ); } + + String _description(AppLocalizations appLocalizations) { + return switch (_progress) { + _SmoothProductCardLoadingProgress.longRequest => + appLocalizations.scan_product_loading_long_request, + _SmoothProductCardLoadingProgress.unresponsive => + appLocalizations.scan_product_loading_unresponsive, + _ => appLocalizations.scan_product_loading_initial, + }; + } + + void _onLongRequest() { + if (!mounted) { + return; + } + setState(() => _progress = _SmoothProductCardLoadingProgress.longRequest); + _timer = Timer(const Duration(seconds: 5), _onUnresponsiveRequest); + } + + void _onUnresponsiveRequest() { + if (!mounted) { + return; + } + setState(() => _progress = _SmoothProductCardLoadingProgress.unresponsive); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } +} + +enum _SmoothProductCardLoadingProgress { + initial, + longRequest, + unresponsive, } diff --git a/packages/smooth_app/lib/helpers/analytics_helper.dart b/packages/smooth_app/lib/helpers/analytics_helper.dart index 8d300225152..20f45fb04ee 100644 --- a/packages/smooth_app/lib/helpers/analytics_helper.dart +++ b/packages/smooth_app/lib/helpers/analytics_helper.dart @@ -13,6 +13,7 @@ enum AnalyticsCategory { userManagement(tag: 'user management'), scanning(tag: 'scanning'), share(tag: 'share'), + loadingProduct(tag: 'loading product'), couldNotFindProduct(tag: 'could not find product'), productEdit(tag: 'product edit'), productFastTrackEdit(tag: 'product fast track edit'), @@ -40,6 +41,14 @@ enum AnalyticsEvent { tag: 'could not find product', category: AnalyticsCategory.couldNotFindProduct, ), + ignoreProductLoading( + tag: 'ignore product', + category: AnalyticsCategory.loadingProduct, + ), + restartProductLoading( + tag: 'restart request', + category: AnalyticsCategory.loadingProduct, + ), ignoreProductNotFound( tag: 'ignore product', category: AnalyticsCategory.couldNotFindProduct, diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index d557b811703..0dca6a48f51 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1955,7 +1955,27 @@ "@scan_header_compare_button_valid_state_tooltip": { "description": "Tooltip (message visible with a long-press) on the Compare button on top of the scanner, when there is at least two prodiucts" }, - "portion_calculator_description": "Calculate nutrition facts for a specific quantity:", + "scan_product_loading": "You have scanned\nthe barcode:", + "@scan_product_loading": { + "description": "Title when a product is loading (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_initial": "We're looking for this product!\nPlease wait a few seconds…", + "@scan_product_loading_initial": { + "description": "Message when a product is loading (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_long_request": "We're still looking for this product!\nDo you find it takes a long time to load? So are we…", + "@scan_product_loading_long_request": { + "description": "Message when a product is long to load (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_unresponsive": "We're still looking for this product.\nWould you like to restart the search?", + "@scan_product_loading_unresponsive": { + "description": "Message when a product is too long to load (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_restart_button": "Restart search", + "@scan_product_loading_restart_button": { + "description": "Button to force restart a product search" + }, + "portion_calculator_description": "Calculate nutrition facts for a specific quantity", "@portion_calculator_description": { "description": "Sort of title that describes the portion calculator." }, diff --git a/packages/smooth_app/lib/l10n/app_fr.arb b/packages/smooth_app/lib/l10n/app_fr.arb index e26d5de4184..eb27e076726 100644 --- a/packages/smooth_app/lib/l10n/app_fr.arb +++ b/packages/smooth_app/lib/l10n/app_fr.arb @@ -1927,6 +1927,26 @@ "@scan_header_compare_button_valid_state_tooltip": { "description": "Tooltip (message visible with a long-press) on the Compare button on top of the scanner, when there is at least two prodiucts" }, + "scan_product_loading": "Vous avez scanné\nle code-barres :", + "@scan_product_loading": { + "description": "Title when a product is loading (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_initial": "Nous cherchons ce produit !\nMerci de patienter quelques instants…", + "@scan_product_loading_initial": { + "description": "Message when a product is loading (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_long_request": "Nous cherchons toujours ce produit !\nLe chargement vous paraît long ? Nous aussi…", + "@scan_product_loading_long_request": { + "description": "Message when a product is long to load (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_unresponsive": "Nous cherchons toujours ce produit !\nVoulez-vous relancer la recherche ?", + "@scan_product_loading_unresponsive": { + "description": "Message when a product is too long to load (carousel card). Please ensure to keep the line break." + }, + "scan_product_loading_restart_button": "Relancer la recherche", + "@scan_product_loading_restart_button": { + "description": "Button to force restart a product search" + }, "portion_calculator_description": "Calculer les valeurs nutritionnelles pour une quantité spécifique", "@portion_calculator_description": { "description": "Sort of title that describes the portion calculator." diff --git a/packages/smooth_app/lib/widgets/smooth_product_carousel.dart b/packages/smooth_app/lib/widgets/smooth_product_carousel.dart index 9846521feb4..3c06ae9c881 100644 --- a/packages/smooth_app/lib/widgets/smooth_product_carousel.dart +++ b/packages/smooth_app/lib/widgets/smooth_product_carousel.dart @@ -178,7 +178,10 @@ class _SmoothProductCarouselState extends State { case ScannedProductState.CACHED: return ScanProductCardLoader(barcode); case ScannedProductState.LOADING: - return SmoothProductCardLoading(barcode: barcode); + return SmoothProductCardLoading( + barcode: barcode, + onRemoveProduct: (_) => _model.removeBarcode(barcode), + ); case ScannedProductState.NOT_FOUND: return SmoothProductCardNotFound( barcode: barcode, diff --git a/packages/smooth_app/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/smooth_app/macos/Flutter/GeneratedPluginRegistrant.swift index fefcf3dac9d..f2845c125d8 100644 --- a/packages/smooth_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/smooth_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import in_app_review import mobile_scanner import package_info_plus import path_provider_foundation +import rive_common import sentry_flutter import share_plus import shared_preferences_foundation @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin")) SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/packages/smooth_app/macos/Podfile.lock b/packages/smooth_app/macos/Podfile.lock index c29e0e1cb54..31cce45a894 100644 --- a/packages/smooth_app/macos/Podfile.lock +++ b/packages/smooth_app/macos/Podfile.lock @@ -20,6 +20,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - rive_common (0.0.1): + - FlutterMacOS - Sentry/HybridSDK (7.31.5) - sentry_flutter (0.0.1): - Flutter @@ -46,6 +48,7 @@ DEPENDENCIES: - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - rive_common (from `Flutter/ephemeral/.symlinks/plugins/rive_common/macos`) - sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -76,6 +79,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + rive_common: + :path: Flutter/ephemeral/.symlinks/plugins/rive_common/macos sentry_flutter: :path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos share_plus: @@ -98,6 +103,7 @@ SPEC CHECKSUMS: mobile_scanner: ed7618fb749adc6574563e053f3b8e5002c13994 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 + rive_common: acedcab7802c0ece4b0d838b71d7deb637e1309a Sentry: 4c9babff9034785067c896fd580b1f7de44da020 sentry_flutter: 1346a880b24c0240807b53b10cf50ddad40f504e share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7