diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index f098449f1398..f3617d8b7b6e 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1645,6 +1645,9 @@ }, "prices_app_dev_mode_flag": "Shortcut to Prices app on product page", "prices_app_button": "Go to Prices app", + "prices_generic_title": "Prices", + "prices_add_a_price": "Add a price", + "prices_view_prices": "View the prices", "dev_preferences_import_history_result_success": "Done", "@dev_preferences_import_history_result_success": { "description": "User dev preferences - Import history - Result successful" diff --git a/packages/smooth_app/lib/l10n/app_fr.arb b/packages/smooth_app/lib/l10n/app_fr.arb index 6921f0feeb34..b0cb935f75da 100644 --- a/packages/smooth_app/lib/l10n/app_fr.arb +++ b/packages/smooth_app/lib/l10n/app_fr.arb @@ -1623,6 +1623,9 @@ }, "prices_app_dev_mode_flag": "Raccourci vers l'application Prix sur la page produit", "prices_app_button": "Accéder à l'application Prix", + "prices_generic_title": "Prix", + "prices_add_a_price": "Ajouter un prix", + "prices_view_prices": "Voir les prix", "dev_preferences_import_history_result_success": "Fait", "@dev_preferences_import_history_result_success": { "description": "User dev preferences - Import history - Result successful" diff --git a/packages/smooth_app/lib/pages/prices/emoji_helper.dart b/packages/smooth_app/lib/pages/prices/emoji_helper.dart new file mode 100644 index 000000000000..77a545b1d9bc --- /dev/null +++ b/packages/smooth_app/lib/pages/prices/emoji_helper.dart @@ -0,0 +1,44 @@ +import 'package:openfoodfacts/openfoodfacts.dart'; + +/// Generic helper about emoji display. +class EmojiHelper { + /// Returns the country flag emoji. + /// + /// cf. https://emojipedia.org/flag-italy + String? getCountryEmoji(final OpenFoodFactsCountry? country) { + if (country == null) { + return null; + } + return _getCountryEmojiFromUnicode(country.offTag); + } + + static const int _emojiCountryLetterA = 0x1F1E6; + static const int _asciiCapitalA = 65; + static const int _asciiCapitalZ = 90; + + static String? _getCountryEmojiFromUnicode(final String unicode) { + final String? countryLetterEmoji1 = _getCountryLetterEmoji( + unicode.substring(0, 1), + ); + if (countryLetterEmoji1 == null) { + return null; + } + //OpenFoodFactsCountry + final String? countryLetterEmoji2 = _getCountryLetterEmoji( + unicode.substring(1, 2), + ); + if (countryLetterEmoji2 == null) { + return null; + } + return '$countryLetterEmoji1$countryLetterEmoji2'; + } + + static String? _getCountryLetterEmoji(final String letter) { + final int ascii = letter.toUpperCase().codeUnitAt(0); + if (ascii < _asciiCapitalA || ascii > _asciiCapitalZ) { + return null; + } + final int code = _emojiCountryLetterA + ascii - _asciiCapitalA; + return String.fromCharCode(code); + } +} diff --git a/packages/smooth_app/lib/pages/prices/prices_card.dart b/packages/smooth_app/lib/pages/prices/prices_card.dart new file mode 100644 index 000000000000..a123e95fc4e8 --- /dev/null +++ b/packages/smooth_app/lib/pages/prices/prices_card.dart @@ -0,0 +1,64 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:openfoodfacts/openfoodfacts.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/helpers/launch_url_helper.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/prices/product_prices_page.dart'; + +/// Card that displays buttons related to prices. +class PricesCard extends StatelessWidget { + const PricesCard(this.product); + + final Product product; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + return buildProductSmoothCard( + body: Container( + width: double.infinity, + padding: const EdgeInsetsDirectional.all(LARGE_SPACE), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).prices_generic_title, + style: Theme.of(context).textTheme.displaySmall, + ), + const SizedBox(height: SMALL_SPACE), + Padding( + padding: const EdgeInsets.all(SMALL_SPACE), + child: SmoothLargeButtonWithIcon( + text: appLocalizations.prices_view_prices, + icon: CupertinoIcons.tag_fill, + onPressed: () async => Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + ProductPricesPage(product), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(SMALL_SPACE), + child: SmoothLargeButtonWithIcon( + text: appLocalizations.prices_add_a_price, + icon: Icons.add, + onPressed: () async => + // TODO(monsieurtanuki): link to the local to-be-developed ProductPriceAddPage page + // TODO(monsieurtanuki): make it work for TEST too + LaunchUrlHelper.launchURL( + 'https://prices.openfoodfacts.org/app/add/single?code=${product.barcode}', + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/pages/prices/product_price_item.dart b/packages/smooth_app/lib/pages/prices/product_price_item.dart new file mode 100644 index 000000000000..bfb6648daced --- /dev/null +++ b/packages/smooth_app/lib/pages/prices/product_price_item.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/helpers/launch_url_helper.dart'; +import 'package:smooth_app/pages/prices/emoji_helper.dart'; +import 'package:smooth_app/pages/product/common/product_query_page_helper.dart'; +import 'package:smooth_app/query/product_query.dart'; + +/// Single product price widget. +class ProductPriceItem extends StatelessWidget { + const ProductPriceItem(this.price); + + final Price price; + + @override + Widget build(BuildContext context) { + final String locale = ProductQuery.getLocaleString(); + final DateFormat dateFormat = DateFormat.yMd(locale); + final DateFormat timeFormat = DateFormat.Hms(locale); + final NumberFormat currencyFormat = NumberFormat.simpleCurrency( + locale: locale, + name: price.currency.name, + ); + final String? locationTitle = _getLocationTitle(price.location); + final double? pricePerKg = _getPricePerKg(price); + return SmoothCard( + child: ListTile( + title: Text( + '${currencyFormat.format(price.price)}' + '${pricePerKg == null ? '' : ' (${currencyFormat.format(pricePerKg)} / kg)'}' + ' ' + '${dateFormat.format(price.date)}', + ), + subtitle: Wrap( + spacing: MEDIUM_SPACE, + children: [ + if (locationTitle != null) + ElevatedButton.icon( + // TODO(monsieurtanuki): open a still-to-be-done "price x location" page + onPressed: () {}, + icon: const Icon(Icons.location_on_outlined), + label: Text(locationTitle), + ), + ElevatedButton.icon( + // TODO(monsieurtanuki): open a still-to-be-done "price x user" page + onPressed: () {}, + icon: const Icon(Icons.account_box), + label: Text(price.owner), + ), + Tooltip( + message: '${dateFormat.format(price.created)}' + ' ' + '${timeFormat.format(price.created)}', + child: ElevatedButton.icon( + // TODO(monsieurtanuki): misleading "active" button + onPressed: () {}, + icon: const Icon(Icons.history), + label: Text( + ProductQueryPageHelper.getDurationStringFromTimestamp( + price.created.millisecondsSinceEpoch, + context, + compact: true, + ), + ), + ), + ), + if (price.proof?.filePath != null) + ElevatedButton( + onPressed: () async => LaunchUrlHelper.launchURL( + // TODO(monsieurtanuki): probably won't work in TEST env + 'https://prices.openfoodfacts.org/img/${price.proof?.filePath}', + ), + child: const Icon(Icons.image), + ), + ], + ), + ), + ); + } + + static double? _getPricePerKg(final Price price) { + if (price.product == null) { + return null; + } + if (price.product!.quantityUnit != 'g') { + return null; + } + return price.price / (price.product!.quantity! / 1000); + } + + static String? _getLocationTitle(final Location? location) { + if (location == null) { + return null; + } + final StringBuffer result = StringBuffer(); + final String? countryEmoji = EmojiHelper().getCountryEmoji( + _getCountry(location), + ); + if (location.name != null) { + result.write(location.name); + } + if (location.city != null) { + if (result.isNotEmpty) { + result.write(', '); + } + result.write(location.city); + } + if (countryEmoji != null) { + if (result.isNotEmpty) { + result.write(' '); + } + result.write(countryEmoji); + } + if (result.isEmpty) { + return null; + } + return result.toString(); + } + + // TODO(monsieurtanuki): enrich the data or find something more elegant + static OpenFoodFactsCountry? _getCountry(final Location location) => + switch (location.country) { + 'France' => OpenFoodFactsCountry.FRANCE, + 'Italia' => OpenFoodFactsCountry.ITALY, + 'Monaco' => OpenFoodFactsCountry.MONACO, + _ => null, + }; +} diff --git a/packages/smooth_app/lib/pages/prices/product_prices_list.dart b/packages/smooth_app/lib/pages/prices/product_prices_list.dart new file mode 100644 index 000000000000..0cedb74e7d5f --- /dev/null +++ b/packages/smooth_app/lib/pages/prices/product_prices_list.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/pages/prices/product_price_item.dart'; +import 'package:smooth_app/query/product_query.dart'; + +/// List of the latest prices for a given product. +class ProductPricesList extends StatefulWidget { + const ProductPricesList(this.barcode); + + final String barcode; + + @override + State createState() => _ProductPricesListState(); +} + +class _ProductPricesListState extends State { + late final Future> _prices = + _showProductPrices(widget.barcode); + + static const int _pageSize = 10; + + // TODO(monsieurtanuki): add a refresh gesture + // TODO(monsieurtanuki): add a "download the next 10" items + // TODO(monsieurtanuki): localize + @override + Widget build(BuildContext context) => + FutureBuilder>( + future: _prices, + builder: ( + final BuildContext context, + final AsyncSnapshot> snapshot, + ) { + if (snapshot.connectionState != ConnectionState.done) { + return const CircularProgressIndicator(); + } + if (snapshot.hasError) { + return Text(snapshot.error!.toString()); + } + // highly improbable + if (!snapshot.hasData) { + return const Text('no data'); + } + if (snapshot.data!.isError) { + return Text(snapshot.data!.error!); + } + final GetPricesResult result = snapshot.data!.value; + // highly improbable + if (result.items == null) { + return const Text('empty list'); + } + final List children = []; + for (final Price price in result.items!) { + children.add(ProductPriceItem(price)); + } + final String title; + if (children.isEmpty) { + title = 'No price for that product yet!'; + } else if (result.total == 1) { + title = 'Only one price found for that product.'; + } else if (result.numberOfPages == 1) { + title = 'All ${result.total} prices for that product'; + } else { + title = + 'Latest $_pageSize prices for that product (total: ${result.total})'; + } + children.insert( + 0, + SmoothCard(child: ListTile(title: Text(title))), + ); + return ListView( + children: children, + ); + }, + ); + + static Future> _showProductPrices( + final String barcode, { + final int pageSize = _pageSize, + final int pageNumber = 1, + }) async => + OpenPricesAPIClient.getPrices( + GetPricesParameters() + ..productCode = barcode + ..orderBy = >[ + const OrderBy( + field: GetPricesOrderField.created, + ascending: false, + ), + ] + ..pageSize = pageSize + ..pageNumber = pageNumber, + uriHelper: ProductQuery.uriProductHelper, + ); +} diff --git a/packages/smooth_app/lib/pages/prices/product_prices_page.dart b/packages/smooth_app/lib/pages/prices/product_prices_page.dart new file mode 100644 index 000000000000..126289de3236 --- /dev/null +++ b/packages/smooth_app/lib/pages/prices/product_prices_page.dart @@ -0,0 +1,66 @@ +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/widgets/smooth_back_button.dart'; +import 'package:smooth_app/helpers/launch_url_helper.dart'; +import 'package:smooth_app/helpers/product_cards_helper.dart'; +import 'package:smooth_app/pages/prices/product_prices_list.dart'; +import 'package:smooth_app/widgets/smooth_app_bar.dart'; +import 'package:smooth_app/widgets/smooth_scaffold.dart'; + +/// Page that displays the latest prices for a given product. +class ProductPricesPage extends StatefulWidget { + const ProductPricesPage(this.product); + + final Product product; + + @override + State createState() => _ProductPricesPageState(); +} + +class _ProductPricesPageState extends State + with UpToDateMixin { + @override + void initState() { + super.initState(); + initUpToDate(widget.product, context.read()); + } + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + context.watch(); + refreshUpToDate(); + final String productName = getProductName( + upToDateProduct, + appLocalizations, + ); + final String productBrand = + getProductBrands(upToDateProduct, appLocalizations); + + return SmoothScaffold( + appBar: SmoothAppBar( + centerTitle: false, + leading: const SmoothBackButton(), + title: Text( + '${productName.trim()}, ${productBrand.trim()}', + maxLines: 2, + ), + actions: [ + IconButton( + tooltip: appLocalizations.prices_app_button, + icon: const Icon(Icons.open_in_new), + onPressed: () async => LaunchUrlHelper.launchURL( + // TODO(monsieurtanuki): make it work for TEST too + 'https://prices.openfoodfacts.org/app/products/${upToDateProduct.barcode!}', + ), + ), + ], + ), + body: ProductPricesList(barcode), + ); + } +} diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart b/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart index 5d7b0a02d683..7c1ff1380865 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page_helper.dart @@ -45,26 +45,47 @@ class ProductQueryPageHelper { } static String getDurationStringFromSeconds( - final int seconds, AppLocalizations appLocalizations) { + final int seconds, + final AppLocalizations appLocalizations, { + final bool compact = false, + }) { final double minutes = seconds / 60; final int roundMinutes = minutes.round(); + if (roundMinutes == 0) { + // TODO(monsieurtanuki): localize if relevant + if (compact) { + return '${seconds}s'; + } + } if (roundMinutes < 60) { + if (compact) { + return '${roundMinutes}m'; + } return appLocalizations.plural_ago_minutes(roundMinutes); } final double hours = minutes / 60; final int roundHours = hours.round(); if (roundHours < 24) { + if (compact) { + return '${roundHours}h'; + } return appLocalizations.plural_ago_hours(roundHours); } final double days = hours / 24; final int roundDays = days.round(); if (roundDays < 7) { + if (compact) { + return '${roundDays}d'; + } return appLocalizations.plural_ago_days(roundDays); } final double weeks = days / 7; final int roundWeeks = weeks.round(); + if (compact) { + return '${roundWeeks}w'; + } if (roundWeeks <= 4) { return appLocalizations.plural_ago_weeks(roundWeeks); } @@ -75,10 +96,17 @@ class ProductQueryPageHelper { } static String getDurationStringFromTimestamp( - final int timestamp, BuildContext context) { + final int timestamp, + final BuildContext context, { + final bool compact = false, + }) { final int now = LocalDatabase.nowInMillis(); final int seconds = ((now - timestamp) / 1000).floor(); - return getDurationStringFromSeconds(seconds, AppLocalizations.of(context)); + return getDurationStringFromSeconds( + seconds, + AppLocalizations.of(context), + compact: compact, + ); } static String getProductListLabel( diff --git a/packages/smooth_app/lib/pages/product/new_product_page.dart b/packages/smooth_app/lib/pages/product/new_product_page.dart index 76245c04f67c..57f7897a7392 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -1,6 +1,5 @@ import 'package:assorted_layout_widgets/assorted_layout_widgets.dart'; import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -22,9 +21,9 @@ import 'package:smooth_app/generic_lib/duration_constants.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; -import 'package:smooth_app/helpers/launch_url_helper.dart'; import 'package:smooth_app/pages/carousel_manager.dart'; import 'package:smooth_app/pages/preferences/user_preferences_dev_mode.dart'; +import 'package:smooth_app/pages/prices/prices_card.dart'; import 'package:smooth_app/pages/product/common/product_list_page.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; import 'package:smooth_app/pages/product/edit_product_page.dart'; @@ -239,16 +238,7 @@ class _ProductPageState extends State if (upToDateProduct.website != null && upToDateProduct.website!.trim().isNotEmpty) WebsiteCard(upToDateProduct.website!), - Padding( - padding: const EdgeInsets.all(SMALL_SPACE), - child: SmoothLargeButtonWithIcon( - text: appLocalizations.prices_app_button, - icon: CupertinoIcons.tag_fill, - onPressed: () async => LaunchUrlHelper.launchURL( - 'https://prices.openfoodfacts.org/app/products/${upToDateProduct.barcode!}', - ), - ), - ), + PricesCard(upToDateProduct), if (userPreferences.getFlag( UserPreferencesDevMode.userPreferencesFlagUserOrderedKP) ?? false) diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index ec253fbe3962..c4ee8a3128cb 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -1132,10 +1132,10 @@ packages: dependency: "direct main" description: name: openfoodfacts - sha256: "19c49aee1093dae611e79240e09ee46f3537b6083235307eec044fb3e5ecc750" + sha256: bb9bc36d08bb57c83dc55e44dc52053aeff77e22685b525bf334706d0f20af36 url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.9.0" openfoodfacts_flutter_lints: dependency: "direct dev" description: diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index e33161782fac..5448ddd02d4b 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -100,7 +100,7 @@ dependencies: path: ../scanner/zxing - openfoodfacts: 3.8.0 + openfoodfacts: 3.9.0 # openfoodfacts: # path: ../../../openfoodfacts-dart