Skip to content

Commit

Permalink
feat: 5197 - first product price page (#5271)
Browse files Browse the repository at this point in the history
* 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
monsieurtanuki authored May 19, 2024
1 parent f9797bc commit 004ec9c
Show file tree
Hide file tree
Showing 11 changed files with 441 additions and 18 deletions.
3 changes: 3 additions & 0 deletions packages/smooth_app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions packages/smooth_app/lib/l10n/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 44 additions & 0 deletions packages/smooth_app/lib/pages/prices/emoji_helper.dart
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);
}
}
64 changes: 64 additions & 0 deletions packages/smooth_app/lib/pages/prices/prices_card.dart
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 packages/smooth_app/lib/pages/prices/product_price_item.dart
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 packages/smooth_app/lib/pages/prices/product_prices_list.dart
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,
);
}
Loading

0 comments on commit 004ec9c

Please sign in to comment.