-
-
Notifications
You must be signed in to change notification settings - Fork 280
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: 5197 - first product price page (#5271)
* feat: 5197 - first product price page New files: * `emoji_helper.dart`: Generic helper about emoji display. * `product_price_item.dart`: Single product price widget. * `product_prices_list.dart`: List of the latest prices for a given product. * `product_prices_page.dart`: Page that displays the latest prices for a given product. Impacted files: * `new_product_page.dart`: now linking to the new `ProductPricesPage` page * `product_query_page_helper.dart`: added a `bool compact` parameter to the `getDurationString`* methods * `pubspec.yaml`: upgraded `openfoodfacts` to `3.9.0` * `pubspec.lock`: wtf * feat: 5197 - new link to "add a price for that product" in web app New file: * `prices_card.dart`: Card that displays buttons related to prices. Impacted files: * `app_en.arb`: added 3 "prices" labels * `app_fr.arb`: added 3 "prices" labels * `new_product_page.dart`: now using the new "PricesCard" widget * `product_prices_list.dart`: minor refactoring * `product_prices_page.dart`: minor refactoring * feat: 5197 - minor fix
- Loading branch information
1 parent
f9797bc
commit 004ec9c
Showing
11 changed files
with
441 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: <Widget>[ | ||
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<void>( | ||
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}', | ||
), | ||
), | ||
), | ||
], | ||
), | ||
), | ||
); | ||
} | ||
} |
130 changes: 130 additions & 0 deletions
130
packages/smooth_app/lib/pages/prices/product_price_item.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: <Widget>[ | ||
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, | ||
}; | ||
} |
95 changes: 95 additions & 0 deletions
95
packages/smooth_app/lib/pages/prices/product_prices_list.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ProductPricesList> createState() => _ProductPricesListState(); | ||
} | ||
|
||
class _ProductPricesListState extends State<ProductPricesList> { | ||
late final Future<MaybeError<GetPricesResult>> _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<MaybeError<GetPricesResult>>( | ||
future: _prices, | ||
builder: ( | ||
final BuildContext context, | ||
final AsyncSnapshot<MaybeError<GetPricesResult>> 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<Widget> children = <Widget>[]; | ||
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<MaybeError<GetPricesResult>> _showProductPrices( | ||
final String barcode, { | ||
final int pageSize = _pageSize, | ||
final int pageNumber = 1, | ||
}) async => | ||
OpenPricesAPIClient.getPrices( | ||
GetPricesParameters() | ||
..productCode = barcode | ||
..orderBy = <OrderBy<GetPricesOrderField>>[ | ||
const OrderBy<GetPricesOrderField>( | ||
field: GetPricesOrderField.created, | ||
ascending: false, | ||
), | ||
] | ||
..pageSize = pageSize | ||
..pageNumber = pageNumber, | ||
uriHelper: ProductQuery.uriProductHelper, | ||
); | ||
} |
Oops, something went wrong.