diff --git a/.github/workflows/dartdoc.yml b/.github/workflows/dartdoc.yml index e803d6dffc9..20a699701cd 100644 --- a/.github/workflows/dartdoc.yml +++ b/.github/workflows/dartdoc.yml @@ -30,7 +30,7 @@ run: pub global activate dartdoc && dartdoc - name: Deploy API documentation to Github Pages - uses: JamesIves/github-pages-deploy-action@v4.2.3 + uses: JamesIves/github-pages-deploy-action@v4.2.5 with: BRANCH: gh-pages FOLDER: packages/smooth_app/doc/api/ diff --git a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_card.dart b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_card.dart index ad26ebffc4c..b1d5fff9dde 100644 --- a/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_card.dart +++ b/packages/smooth_app/lib/cards/product_cards/knowledge_panels/knowledge_panel_card.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:smooth_app/cards/product_cards/knowledge_panels/knowledge_panel_expanded_card.dart'; import 'package:smooth_app/cards/product_cards/knowledge_panels/knowledge_panel_summary_card.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; import 'package:smooth_app/themes/smooth_theme.dart'; import 'package:smooth_app/themes/theme_provider.dart'; @@ -31,31 +32,36 @@ class KnowledgePanelCard extends StatelessWidget { } return InkWell( child: KnowledgePanelSummaryCard(panel), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext context) => Scaffold( - backgroundColor: SmoothTheme.getColor( - themeData.colorScheme, - SmoothTheme.getMaterialColor(themeProvider), - ColorDestination.SURFACE_BACKGROUND, - ), - appBar: AppBar(), - body: SingleChildScrollView( - child: SmoothCard( - color: const Color(0xfff5f6fa), - padding: const EdgeInsets.all( - SMALL_SPACE, - ), - child: KnowledgePanelExpandedCard( - panel: panel, - allPanels: allPanels, + onTap: () { + AnalyticsHelper.trackKnowledgePanelOpen( + knowledgePanelName: panel.topics.toString(), + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => Scaffold( + backgroundColor: SmoothTheme.getColor( + themeData.colorScheme, + SmoothTheme.getMaterialColor(themeProvider), + ColorDestination.SURFACE_BACKGROUND, + ), + appBar: AppBar(), + body: SingleChildScrollView( + child: SmoothCard( + color: const Color(0xfff5f6fa), + padding: const EdgeInsets.all( + SMALL_SPACE, + ), + child: KnowledgePanelExpandedCard( + panel: panel, + allPanels: allPanels, + ), ), ), ), ), - ), - ), + ); + }, ); } } diff --git a/packages/smooth_app/lib/data_models/continuous_scan_model.dart b/packages/smooth_app/lib/data_models/continuous_scan_model.dart index 0722742a048..4a0a0eb74a9 100644 --- a/packages/smooth_app/lib/data_models/continuous_scan_model.dart +++ b/packages/smooth_app/lib/data_models/continuous_scan_model.dart @@ -7,6 +7,7 @@ import 'package:smooth_app/database/barcode_product_query.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; enum ScannedProductState { FOUND, @@ -32,7 +33,6 @@ class ContinuousScanModel with ChangeNotifier { late DaoProduct _daoProduct; late DaoProductList _daoProductList; - bool get hasMoreThanOneProduct => getBarcodes().length > 1; ProductList get productList => _productList; List getBarcodes() => _barcodes; @@ -99,6 +99,8 @@ class ContinuousScanModel with ChangeNotifier { if (_latestScannedBarcode == code) { return; } + AnalyticsHelper.trackScannedProduct(barcode: code); + _latestScannedBarcode = code; _addBarcode(code); } diff --git a/packages/smooth_app/lib/data_models/product_query_model.dart b/packages/smooth_app/lib/data_models/product_query_model.dart index b02ba62174b..4e2c7658132 100644 --- a/packages/smooth_app/lib/data_models/product_query_model.dart +++ b/packages/smooth_app/lib/data_models/product_query_model.dart @@ -21,6 +21,7 @@ class ProductQueryModel with ChangeNotifier { final ProductListSupplier supplier; static const String _CATEGORY_ALL = 'all'; + String currentCategory = _CATEGORY_ALL; LoadingStatus _loadingStatus = LoadingStatus.LOADING; String? _loadingError; @@ -108,6 +109,7 @@ class ProductQueryModel with ChangeNotifier { } void selectCategory(String category) { + currentCategory = category; if (category == _CATEGORY_ALL) { displayProducts = _products; } else { diff --git a/packages/smooth_app/lib/database/dao_int.dart b/packages/smooth_app/lib/database/dao_int.dart new file mode 100644 index 00000000000..8cec1f50679 --- /dev/null +++ b/packages/smooth_app/lib/database/dao_int.dart @@ -0,0 +1,23 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:smooth_app/database/abstract_dao.dart'; +import 'package:smooth_app/database/local_database.dart'; + +/// Where we store ints. +class DaoInt extends AbstractDao { + DaoInt(final LocalDatabase localDatabase) : super(localDatabase); + + static const String _hiveBoxName = 'int'; + + @override + Future init() async => Hive.openBox(_hiveBoxName); + + @override + void registerAdapter() {} + + Box _getBox() => Hive.box(_hiveBoxName); + + int? get(final String key) => _getBox().get(key); + + Future put(final String key, final int? value) async => + value == null ? _getBox().delete(key) : _getBox().put(key, value); +} diff --git a/packages/smooth_app/lib/database/local_database.dart b/packages/smooth_app/lib/database/local_database.dart index d448a927e5d..a3c064befe6 100644 --- a/packages/smooth_app/lib/database/local_database.dart +++ b/packages/smooth_app/lib/database/local_database.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:smooth_app/database/abstract_dao.dart'; +import 'package:smooth_app/database/dao_int.dart'; import 'package:smooth_app/database/dao_product.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/dao_string.dart'; @@ -26,6 +27,7 @@ class LocalDatabase extends ChangeNotifier { DaoProductList(localDatabase), DaoStringList(localDatabase), DaoString(localDatabase), + DaoInt(localDatabase), ]; for (final AbstractDao dao in daos) { dao.registerAdapter(); diff --git a/packages/smooth_app/lib/helpers/analytics_helper.dart b/packages/smooth_app/lib/helpers/analytics_helper.dart new file mode 100644 index 00000000000..246d854fcd0 --- /dev/null +++ b/packages/smooth_app/lib/helpers/analytics_helper.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matomo_forever/matomo_forever.dart'; +import 'package:openfoodfacts/model/Product.dart'; +import 'package:openfoodfacts/utils/OpenFoodAPIConfiguration.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/helpers/tracking_database_helper.dart'; + +// TODO(m123): Check for user consent + +/// Helper for logging usage of core features and exceptions +/// Logging: +/// - Errors and Problems (sentry) +/// - App start +/// - Product scan +/// - Product page open +/// - Knowledge panel open +/// - personalized ranking (without sharing the preferences) +/// - search +/// - external links +class AnalyticsHelper { + AnalyticsHelper._(); + + static const String _initAction = 'started app'; + static const String _scanAction = 'scanned product'; + static const String _productPageAction = 'opened product page'; + static const String _knowledgePanelAction = 'opened knowledge panel page'; + static const String _personalizedRankingAction = 'personalized ranking'; + static const String _searchAction = 'search'; + static const String _linkAction = 'opened link'; + + /// The event category. Must not be empty. (eg. Videos, Music, Games...) + static const String _eventCategory = 'e_c'; + + /// Must not be empty. (eg. Play, Pause, Duration, Add + /// Playlist, Downloaded, Clicked...) + static const String _eventAction = 'e_a'; + + /// The event name. (eg. a Movie name, or Song name, or File name...) + static const String _eventName = 'e_n'; + + /// Must be a float or integer value (numeric), not a string. + static const String _eventValue = 'e_v'; + + static String latestSearch = ''; + + static Future initSentry({Function()? appRunner}) async { + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + await SentryFlutter.init( + (SentryOptions options) { + options.dsn = + 'https://22ec5d0489534b91ba455462d3736680@o241488.ingest.sentry.io/5376745'; + options.sentryClientName = + 'sentry.dart.smoothie/${packageInfo.version}'; + }, + appRunner: appRunner, + ); + } + + static Future initMatomo( + final BuildContext context, + final LocalDatabase _localDatabase, + ) async { + MatomoForever.init( + 'https://analytics.openfoodfacts.org/matomo.php', + 2, + id: _getId(), + // If we track or not, should be decidable later + rec: true, + method: MatomoForeverMethod.post, + sendImage: false, + // 32 character authorization key used to authenticate the API request + // only needed for request which are more then 24h old + // tokenAuth: 'xxx', + ); + } + + static Future trackStart( + LocalDatabase _localDatabase, BuildContext context) async { + final TrackingDatabaseHelper trackingDatabaseHelper = + TrackingDatabaseHelper(_localDatabase); + final Size size = MediaQuery.of(context).size; + final Map data = {}; + + // The current count of visits for this visitor + data.addIfVAndNew( + '_idvc', trackingDatabaseHelper.getAppVisits().toString()); + // The UNIX timestamp of this visitor's previous visit + data.addIfVAndNew( + '_viewts', + trackingDatabaseHelper.getPreviousVisitUnix().toString(), + ); + // The UNIX timestamp of this visitor's first visit + data.addIfVAndNew( + '_idts', + trackingDatabaseHelper.getFirstVisitUnix().toString(), + ); + // Device resolution + data.addIfVAndNew('res', '${size.width}x${size.height}'); + data.addIfVAndNew('lang', Localizations.localeOf(context).languageCode); + data.addIfVAndNew('country', Localizations.localeOf(context).countryCode); + + return _track( + _initAction, + data, + ); + } + + // TODO(m123): Matomo removes leading 0 from the barcode + static Future trackScannedProduct({required String barcode}) => _track( + _scanAction, + { + _eventCategory: 'Scanner', + _eventAction: 'Scanned', + _eventValue: barcode, + }, + ); + + static Future trackProductPageOpen({ + required Product product, + }) { + final Map data = { + _eventCategory: 'Product page', + _eventAction: 'opened', + }; + data.addIfVAndNew(_eventValue, product.productName); + data.addIfVAndNew(_eventName, product.productName); + + return _track( + _productPageAction, + data, + ); + } + + static Future trackKnowledgePanelOpen({ + String? knowledgePanelName, + }) { + final Map data = { + _eventCategory: 'Knowledge panel', + _eventAction: 'opened', + }; + data.addIfVAndNew(_eventName, knowledgePanelName); + + return _track( + _knowledgePanelAction, + data, + ); + } + + static Future trackPersonalizedRanking({ + required String title, + required int products, + required int goodProducts, + required int badProducts, + required int unknownProducts, + }) => + _track( + _personalizedRankingAction, + { + 'title': title, + 'productsCount': '$products', + 'goodProducts': '$goodProducts', + 'badProducts': '$badProducts', + 'unkownProducts': '$unknownProducts', + }, + ); + + static void trackSearch({ + required String search, + String? searchCategory, + int? searchCount, + }) { + final Map data = { + 'search': search, + }; + data.addIfVAndNew('search_cat', searchCategory); + data.addIfVAndNew('search_count', searchCount); + + if (data.toString() == latestSearch) { + return; + } + latestSearch = data.toString(); + + _track( + _searchAction, + data, + ); + } + + static Future trackOpenLink({required String url}) => _track( + _linkAction, + { + 'url': url, + 'link': url, + }, + ); + + static Future _track(String actionName, Map data) { + final DateTime date = DateTime.now(); + final Map addedData = { + 'action_name': actionName, + //Random number to avoid the tracking request being cached by the browser or a proxy. + 'rand': Random().nextInt(1000).toString(), + //Adding the tracking time + 'h': date.hour.toString(), + 'm': date.minute.toString(), + 's': date.second.toString(), + }; + // User identifier + addedData.addIfVAndNew('uid', _getId()); + addedData.addAll(data); + + return MatomoForever.sendDataOrBulk(addedData); + } + + static String? _getId() { + return kDebugMode ? 'smoothie-debug' : OpenFoodAPIConfiguration.uuid; + } +} diff --git a/packages/smooth_app/lib/helpers/launch_url_helper.dart b/packages/smooth_app/lib/helpers/launch_url_helper.dart index 9c08c933139..3e07060db4a 100644 --- a/packages/smooth_app/lib/helpers/launch_url_helper.dart +++ b/packages/smooth_app/lib/helpers/launch_url_helper.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:url_launcher/url_launcher.dart'; class LaunchUrlHelper { @@ -11,28 +12,36 @@ class LaunchUrlHelper { /// static Future launchURL(String url, bool isOFF) async { if (isOFF) { - if (!url.contains('https://openfoodfacts.')) { - throw 'Error do not use local identifier in url'; - } - - String? countryCode = WidgetsBinding.instance == null - ? null - : WidgetsBinding.instance!.window.locale.countryCode; - - if (countryCode == null) { - countryCode = 'world.'; - } else { - countryCode = '$countryCode.'; - } - - url = url.replaceAll( - 'https://openfoodfacts.', 'https://${countryCode}openfoodfacts.'); + url = _replaceSubdomainWithCodes(url); } + AnalyticsHelper.trackOpenLink(url: url); + if (await canLaunch(url)) { await launch(url); } else { throw 'Could not launch $url'; } } + + static String _replaceSubdomainWithCodes(String url) { + if (!url.contains('https://openfoodfacts.')) { + throw 'Error do not use local identifier in url'; + } + + String? countryCode = WidgetsBinding.instance == null + ? null + : WidgetsBinding.instance!.window.locale.countryCode?.toLowerCase(); + + if (countryCode == null) { + countryCode = 'world.'; + } else { + countryCode = '$countryCode.'; + } + + url = url.replaceAll( + 'https://openfoodfacts.', 'https://${countryCode}openfoodfacts.'); + + return url; + } } diff --git a/packages/smooth_app/lib/helpers/tracking_database_helper.dart b/packages/smooth_app/lib/helpers/tracking_database_helper.dart new file mode 100644 index 00000000000..0c54e027be1 --- /dev/null +++ b/packages/smooth_app/lib/helpers/tracking_database_helper.dart @@ -0,0 +1,52 @@ +import 'package:smooth_app/database/dao_int.dart'; +import 'package:smooth_app/database/local_database.dart'; + +class TrackingDatabaseHelper { + const TrackingDatabaseHelper(this._localDatabase); + final LocalDatabase _localDatabase; + + /// Returns the amount the user has opened the app + int getAppVisits() { + const String _userVisits = 'appVisits'; + + final DaoInt daoInt = DaoInt(_localDatabase); + + int visits = daoInt.get(_userVisits) ?? 0; + visits++; + daoInt.put(_userVisits, visits); + + return visits; + } + + int? getPreviousVisitUnix() { + const String _latestVisit = 'previousVisitUnix'; + + final DaoInt daoInt = DaoInt(_localDatabase); + + final int? latestVisit = daoInt.get(_latestVisit); + + daoInt.put( + _latestVisit, + DateTime.now().millisecondsSinceEpoch, + ); + + return latestVisit; + } + + int? getFirstVisitUnix() { + const String _firstVisit = 'firstVisitUnix'; + + final DaoInt daoInt = DaoInt(_localDatabase); + + final int? firstVisit = daoInt.get(_firstVisit); + + if (firstVisit == null) { + daoInt.put( + _firstVisit, + DateTime.now().millisecondsSinceEpoch, + ); + } + + return firstVisit; + } +} diff --git a/packages/smooth_app/lib/main.dart b/packages/smooth_app/lib/main.dart index a86fef71a93..b80c46a0484 100644 --- a/packages/smooth_app/lib/main.dart +++ b/packages/smooth_app/lib/main.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:openfoodfacts/personalized_search/product_preferences_selection.dart'; -import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; @@ -15,6 +14,7 @@ import 'package:smooth_app/data_models/user_preferences.dart'; import 'package:smooth_app/database/dao_string.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/user_management_helper.dart'; import 'package:smooth_app/pages/onboarding/onboarding_flow_navigator.dart'; import 'package:smooth_app/themes/smooth_theme.dart'; @@ -24,27 +24,16 @@ List cameras = []; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - final PackageInfo packageInfo = await PackageInfo.fromPlatform(); if (kReleaseMode) { - await SentryFlutter.init( - (SentryOptions options) { - options.dsn = - 'https://22ec5d0489534b91ba455462d3736680@o241488.ingest.sentry.io/5376745'; - options.sentryClientName = - 'sentry.dart.smoothie/${packageInfo.version}'; - }, - appRunner: () => runApp(const SmoothApp()), - ); - - /* TODO: put back when we have clearer ideas about analytics - await MatomoTracker().initialize( - siteId: 2, - url: 'https://analytics.openfoodfacts.org/', + await AnalyticsHelper.initSentry( + appRunner: () => const SmoothApp(), ); - */ } else { - runApp(DevicePreview(enabled: true, builder: (_) => const SmoothApp())); + runApp(DevicePreview( + enabled: true, + builder: (_) => const SmoothApp(), + )); } } @@ -95,9 +84,11 @@ class _SmoothAppState extends State { _themeProvider = ThemeProvider(_userPreferences); ProductQuery.setQueryType(_userPreferences); - UserManagementHelper.mountCredentials(); cameras = await availableCameras(); + + UserManagementHelper.mountCredentials(); await ProductQuery.setUuid(_localDatabase); + await AnalyticsHelper.initMatomo(context, _localDatabase); } @override @@ -177,11 +168,26 @@ class _SmoothAppState extends State { /// Layer needed because we need to know the language. Language isn't available /// in the [context] in top level widget ([SmoothApp]) -class SmoothAppGetLanguage extends StatelessWidget { +class SmoothAppGetLanguage extends StatefulWidget { const SmoothAppGetLanguage(this.appWidget); final Widget appWidget; + @override + State createState() => _SmoothAppGetLanguageState(); +} + +// Currently converted into a StatefulWidget to call trackStart in initState +// since this widget got rebuild multiple time which it shouldn't +// TODO(open): Fix unnecessary rebuilds +class _SmoothAppGetLanguageState extends State { + @override + void initState() { + final LocalDatabase _localDatabase = context.read(); + AnalyticsHelper.trackStart(_localDatabase, context); + super.initState(); + } + @override Widget build(BuildContext context) { final ProductPreferences productPreferences = @@ -190,6 +196,7 @@ class SmoothAppGetLanguage extends StatelessWidget { final String languageCode = myLocale.languageCode; ProductQuery.setLanguage(languageCode); productPreferences.refresh(languageCode); - return appWidget; + + return widget.appWidget; } } diff --git a/packages/smooth_app/lib/pages/personalized_ranking_page.dart b/packages/smooth_app/lib/pages/personalized_ranking_page.dart index 43fd4c4a9bd..cb6d187f08e 100644 --- a/packages/smooth_app/lib/pages/personalized_ranking_page.dart +++ b/packages/smooth_app/lib/pages/personalized_ranking_page.dart @@ -9,6 +9,7 @@ import 'package:smooth_app/data_models/smooth_it_model.dart'; import 'package:smooth_app/data_models/user_preferences.dart'; import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/smooth_matched_product.dart'; class PersonalizedRankingPage extends StatefulWidget { @@ -73,6 +74,15 @@ class _PersonalizedRankingPageState extends State { ); colors.add(_COLORS[matchTab]!); } + + AnalyticsHelper.trackPersonalizedRanking( + title: widget.title, + products: matchedProductsList[0].length, + goodProducts: matchedProductsList[1].length, + badProducts: matchedProductsList[2].length, + unknownProducts: matchedProductsList[3].length, + ); + return DefaultTabController( length: _ORDERED_MATCH_TABS.length, initialIndex: _ORDERED_MATCH_TABS.indexOf(MatchTab.YES), diff --git a/packages/smooth_app/lib/pages/product/common/product_query_page.dart b/packages/smooth_app/lib/pages/product/common/product_query_page.dart index 89f5b58f2c8..a6107078fb3 100644 --- a/packages/smooth_app/lib/pages/product/common/product_query_page.dart +++ b/packages/smooth_app/lib/pages/product/common/product_query_page.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_found.dart'; import 'package:smooth_app/data_models/product_list_supplier.dart'; import 'package:smooth_app/data_models/product_query_model.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/pages/personalized_ranking_page.dart'; import 'package:smooth_app/pages/product/common/product_query_page_helper.dart'; import 'package:smooth_app/themes/constant_icons.dart'; @@ -88,6 +89,11 @@ class _ProductQueryPageState extends State { case LoadingStatus.COMPLETE: if (_model.isNotEmpty()) { _showRefreshSnackBar(_scaffoldKeyNotEmpty); + AnalyticsHelper.trackSearch( + search: widget.name, + searchCategory: _model.currentCategory, + searchCount: _model.displayProducts?.length, + ); return _getNotEmptyScreen(screenSize, themeData); } _showRefreshSnackBar(_scaffoldKeyEmpty); 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 f0b243a8b68..9833a9a0316 100644 --- a/packages/smooth_app/lib/pages/product/new_product_page.dart +++ b/packages/smooth_app/lib/pages/product/new_product_page.dart @@ -13,6 +13,7 @@ import 'package:smooth_app/database/dao_product_list.dart'; import 'package:smooth_app/database/knowledge_panels_query.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/product_query.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/launch_url_helper.dart'; import 'package:smooth_app/helpers/product_cards_helper.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; @@ -45,6 +46,9 @@ class _ProductPageState extends State { super.initState(); _product = widget.product; _updateLocalDatabaseWithProductHistory(context, _product); + AnalyticsHelper.trackProductPageOpen( + product: _product, + ); } @override diff --git a/packages/smooth_app/lib/pages/product/nutrition_container.dart b/packages/smooth_app/lib/pages/product/nutrition_container.dart new file mode 100644 index 00000000000..6ac9b28edbe --- /dev/null +++ b/packages/smooth_app/lib/pages/product/nutrition_container.dart @@ -0,0 +1,318 @@ +import 'package:openfoodfacts/interface/JsonObject.dart'; +import 'package:openfoodfacts/model/Nutriments.dart'; +import 'package:openfoodfacts/model/OrderedNutrient.dart'; +import 'package:openfoodfacts/model/OrderedNutrients.dart'; +import 'package:openfoodfacts/model/Product.dart'; +import 'package:openfoodfacts/utils/UnitHelper.dart'; +import 'package:smooth_app/pages/product/tmp_to_off_nutriments.dart'; + +/// Nutrition data, for nutrient order and conversions. +class NutritionContainer { + NutritionContainer({ + required final OrderedNutrients orderedNutrients, + required final Product product, + }) { + _loadNutrients(orderedNutrients.nutrients); + final Map? json = product.nutriments?.toJson(); + if (json != null) { + _loadUnits(json); + _loadValues(json); + } + _servingSize = product.servingSize; + _barcode = product.barcode!; + } + + static const String _energyId = 'energy'; + + /// special case: present id [OrderedNutrient] but not in [Nutriments] map. + static const String _energyKJId = 'energy-kj'; + static const String _energyKCalId = 'energy-kcal'; + static const String fakeNutrientIdServingSize = '_servingSize'; + + static const Map _nextWeightUnits = { + Unit.G: Unit.MILLI_G, + Unit.MILLI_G: Unit.MICRO_G, + Unit.MICRO_G: Unit.G, + }; + + // For the moment we only care about "weight or not weight?" + // Could be refined with values taken from https://static.openfoodfacts.org/data/taxonomies/nutrients.json + // Fun fact: most of them are not supported (yet) by [Nutriments]. + static const Map _defaultNotWeightUnits = { + _energyId: Unit.KJ, + _energyKCalId: Unit.KCAL, + 'alcohol': Unit.PERCENT, + 'cocoa': Unit.PERCENT, + 'collagen-meat-protein-ratio': Unit.PERCENT, + 'fruits-vegetables-nuts': Unit.PERCENT, + 'fruits-vegetables-nuts-dried': Unit.PERCENT, + 'fruits-vegetables-nuts-estimate': Unit.PERCENT, + }; + + /// All the nutrients (country-related). + final List _nutrients = []; + + /// Nutrient values for 100g and serving. + final Map _values = {}; + + /// Nutrient units. + final Map _units = {}; + + /// Nutrient Ids added by the end-user + final Set _added = {}; + + String? _servingSize; + + String? get servingSize => _servingSize; + + late final String _barcode; + + /// Returns the not interesting nutrients, for a "Please add me!" list. + Iterable getLeftoverNutrients() => _nutrients.where( + (final OrderedNutrient element) => _isNotRelevant(element), + ); + + /// Returns the interesting nutrients that need to be displayed. + Iterable getDisplayableNutrients() => _nutrients.where( + (final OrderedNutrient element) => !_isNotRelevant(element), + ); + + /// Returns true if the [OrderedNutrient] is not relevant. + bool _isNotRelevant(final OrderedNutrient orderedNutrient) { + final String nutrientId = orderedNutrient.id; + final double? value100g = getValue(getValueKey(nutrientId, false)); + final double? valueServing = getValue(getValueKey(nutrientId, true)); + return value100g == null && + valueServing == null && + (!orderedNutrient.important) && + (!_added.contains(nutrientId)); + } + + /// Returns a [Product] with only nutrients data. + Product getProduct() => Product( + barcode: _barcode, + nutriments: _getNutriments(), + servingSize: _servingSize, + ); + + /// Converts all the data to a [Nutriments]. + Nutriments _getNutriments() { + /// Converts a (weight) value to grams (before sending a value to the BE) + double? _convertWeightToG(final double? value, final Unit unit) { + if (value == null) { + return null; + } + if (unit == Unit.MILLI_G) { + return value / 1E3; + } + if (unit == Unit.MICRO_G) { + return value / 1E6; + } + return value; + } + + final Map map = {}; + for (final OrderedNutrient orderedNutrient in getDisplayableNutrients()) { + final String nutrientId = orderedNutrient.id; + final String key100g = getValueKey(nutrientId, false); + final String keyServing = getValueKey(nutrientId, true); + final double? value100g = getValue(key100g); + final double? valueServing = getValue(keyServing); + if (value100g == null && valueServing == null) { + continue; + } + final Unit unit = getUnit(nutrientId); + if (value100g != null) { + map[key100g] = _convertWeightToG(value100g, unit); + } + if (valueServing != null) { + //map[keyServing] = _convertWeightToG(valueServing, unit); + } + map[_getNutrimentsUnitKey(nutrientId)] = UnitHelper.unitToString(unit); + } + + return Nutriments.fromJson(map); + } + + /// Returns the stored product nutrient's value. + double? getValue(final String valueKey) => _values[valueKey]; + + /// Stores the text from the end-user input. + void setControllerText(final String controllerKey, final String text) { + if (controllerKey == fakeNutrientIdServingSize) { + _servingSize = text.trim().isEmpty ? null : text; + return; + } + + double? value; + if (text.isNotEmpty) { + try { + value = double.parse(text.replaceAll(',', '.')); + } catch (e) { + // + } + } + if (value == null) { + _values.remove(controllerKey); + } else { + _values[controllerKey] = value; + } + } + + /// Typical use-case: should we make the [Unit] button clickable? + static bool isEditableWeight(final OrderedNutrient orderedNutrient) => + _getDefaultUnit(orderedNutrient.id) == null; + + /// Typical use-case: [Unit] button action. + void setNextWeightUnit(final OrderedNutrient orderedNutrient) { + final Unit unit = getUnit(orderedNutrient.id); + _setUnit(orderedNutrient.id, _nextWeightUnits[unit] ?? unit); + } + + /// Returns the nutrient [Unit], after possible alterations. + Unit getUnit(String nutrientId) { + nutrientId = _fixNutrientId(nutrientId); + switch (nutrientId) { + case _energyId: + case _energyKJId: + return Unit.KJ; + case _energyKCalId: + return Unit.KCAL; + default: + return _units[nutrientId] ?? _getDefaultUnit(nutrientId) ?? Unit.G; + } + } + + /// Stores the nutrient [Unit]. + void _setUnit(final String nutrientId, final Unit unit) => + _units[_fixNutrientId(nutrientId)] = unit; + + static Unit? _getDefaultUnit(final String nutrientId) => + _defaultNotWeightUnits[_fixNutrientId(nutrientId)]; + + /// To be used when an [OrderedNutrient] is added to the input list + void add(final OrderedNutrient orderedNutrient) => + _added.add(orderedNutrient.id); + + /// Returns the [Nutriments] map key for the nutrient value. + /// + /// * [perServing] true: per serving. + /// * [perServing] false: per 100g. + static String getValueKey( + String nutrientId, + final bool perServing, + ) { + nutrientId = _fixNutrientId(nutrientId); + // 'energy-kcal' is directly for serving (no 'energy-kcal_serving') + if (nutrientId == _energyKCalId && perServing) { + return _energyKCalId; + } + return '$nutrientId${perServing ? '_serving' : '_100g'}'; + } + + /// Returns a vertical list of nutrients from a tree structure. + /// + /// Typical use-case: to be used from BE's tree nutrients in order to get + /// a simple one-dimension list, easier to display and parse. + /// For some countries, there's energy or energyKJ, or both + /// cf. https://github.com/openfoodfacts/openfoodfacts-server/blob/main/lib/ProductOpener/Food.pm + /// Regarding our list of nutrients here, we need one and only one of them. + void _loadNutrients( + final List nutrients, + ) { + bool alreadyEnergyKJ = false; + + // inner method, in order to use alreadyEnergyKJ without a private variable. + void _populateOrderedNutrientList(final List list) { + for (final OrderedNutrient nutrient in list) { + if (nutrient.id != _energyKJId && + !TmpToOffNutriments.supportedNutrientIds.contains(nutrient.id)) { + continue; + } + final bool nowEnergy = + nutrient.id == _energyId || nutrient.id == _energyKJId; + bool addNutrient = true; + if (nowEnergy) { + if (alreadyEnergyKJ) { + addNutrient = false; + } + alreadyEnergyKJ = true; + } + if (addNutrient) { + _nutrients.add(nutrient); + } + if (nutrient.subNutrients != null) { + _populateOrderedNutrientList(nutrient.subNutrients!); + } + } + } + + _populateOrderedNutrientList(nutrients); + + if (!alreadyEnergyKJ) { + throw Exception('no energy or energyKJ found: very suspicious!'); + } + } + + /// Returns the unit key according to [Nutriments] json map. + static String _getNutrimentsUnitKey(final String nutrientId) => + '${_fixNutrientId(nutrientId)}_unit'; + + static String _fixNutrientId(final String nutrientId) => + nutrientId == _energyKJId ? _energyId : nutrientId; + + /// Loads product nutrient units into a map. + /// + /// Needs nutrients to be loaded first. + void _loadUnits(final Map json) { + for (final OrderedNutrient orderedNutrient in _nutrients) { + final String nutrientId = orderedNutrient.id; + final String unitKey = _getNutrimentsUnitKey(nutrientId); + final dynamic value = json[unitKey]; + if (value == null || value is! String) { + continue; + } + final Unit? unit = UnitHelper.stringToUnit(value); + if (unit != null) { + _setUnit(nutrientId, unit); + } + } + } + + /// Loads product nutrients into a map. + /// + /// Needs nutrients and units to be loaded first. + void _loadValues(final Map json) { + /// Converts a double (weight) value from grams. + /// + /// Typical use-case: after receiving a value from the BE. + double? _convertWeightFromG(final double? value, final Unit unit) { + if (value == null) { + return null; + } + if (unit == Unit.MILLI_G) { + return value * 1E3; + } + if (unit == Unit.MICRO_G) { + return value * 1E6; + } + return value; + } + + for (final OrderedNutrient orderedNutrient in _nutrients) { + final String nutrientId = orderedNutrient.id; + final Unit unit = getUnit(nutrientId); + for (int i = 0; i < 2; i++) { + final bool perServing = i == 0; + final String valueKey = getValueKey(nutrientId, perServing); + final double? value = _convertWeightFromG( + JsonObject.parseDouble(json[valueKey]), + unit, + ); + if (value != null) { + _values[valueKey] = value; + } + } + } + } +} diff --git a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart index d8cb74145f3..f501b02d8f6 100644 --- a/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart +++ b/packages/smooth_app/lib/pages/product/nutrition_page_loaded.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; -import 'package:openfoodfacts/interface/JsonObject.dart'; -import 'package:openfoodfacts/model/Nutriments.dart'; import 'package:openfoodfacts/model/OrderedNutrient.dart'; import 'package:openfoodfacts/model/OrderedNutrients.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; @@ -13,6 +11,7 @@ import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/database/product_query.dart'; import 'package:smooth_app/helpers/ui_helpers.dart'; import 'package:smooth_app/pages/product/common/product_refresher.dart'; +import 'package:smooth_app/pages/product/nutrition_container.dart'; /// Actual nutrition page, with data already loaded. class NutritionPageLoaded extends StatefulWidget { @@ -26,47 +25,29 @@ class NutritionPageLoaded extends StatefulWidget { } class _NutritionPageLoadedState extends State { - final List _displayableList = []; - late Map _values; - late RegExp _decimalRegExp; - late NumberFormat _numberFormat; + late final RegExp _decimalRegExp; + late final NumberFormat _numberFormat; + late final NutritionContainer _nutritionContainer; bool _unspecified = false; // TODO(monsieurtanuki): fetch that data from API? + // If true then serving, if false then 100g. bool _servingOr100g = false; static const double _columnSize1 = 250; // TODO(monsieurtanuki): proper size static const double _columnSize2 = 100; // TODO(monsieurtanuki): anyway, should fit the largest text, probably 'mcg/µg' - static const String _fakeNutrientIdServingSize = '_servingSize'; - static const String _energyId = 'energy'; - static const String _energyKJId = 'energy-kj'; - static const String _energyKCalId = 'energy-kcal'; - final Map _controllers = {}; - final Map _units = {}; final GlobalKey _formKey = GlobalKey(); - static const Map _nextWeightUnits = { - Unit.G: Unit.MILLI_G, - Unit.MILLI_G: Unit.MICRO_G, - Unit.MICRO_G: Unit.G, - }; - static const Map _unitLabels = { - Unit.G: 'g', - Unit.MILLI_G: 'mg', - Unit.MICRO_G: 'mcg/µg', - Unit.KJ: 'kJ', - Unit.KCAL: 'kcal', - Unit.PERCENT: '%', - }; - @override void initState() { super.initState(); - _populateOrderedNutrientList(widget.orderedNutrients.nutrients); - _values = widget.product.nutriments!.toJson(); + _nutritionContainer = NutritionContainer( + orderedNutrients: widget.orderedNutrients, + product: widget.product, + ); _numberFormat = NumberFormat('####0.#####', ProductQuery.getLocaleString()); _decimalRegExp = _numberFormat.format(1.2).contains('.') ? RegExp(r'[0-9\.]') // TODO(monsieurtanuki): check if . or \. @@ -90,14 +71,14 @@ class _NutritionPageLoadedState extends State { if (!_unspecified) { children.add(_getServingField(appLocalizations)); children.add(_getServingSwitch(appLocalizations)); - for (final OrderedNutrient orderedNutrient in _displayableList) { - final Widget? item = _getNutrientWidget( - appLocalizations, - orderedNutrient, + for (final OrderedNutrient orderedNutrient + in _nutritionContainer.getDisplayableNutrients()) { + children.add( + _getNutrientRow( + appLocalizations, + orderedNutrient, + ), ); - if (item != null) { - children.add(item); - } } children.add(_addNutrientButton(appLocalizations)); } @@ -118,85 +99,46 @@ class _NutritionPageLoadedState extends State { ); } - void _populateOrderedNutrientList(final List? list) { - if (list == null) { - return; - } - for (final OrderedNutrient nutrient in list) { - _displayableList.add(nutrient); - _populateOrderedNutrientList(nutrient.subNutrients); - } - } - - Widget? _getNutrientWidget( + Widget _getNutrientRow( final AppLocalizations appLocalizations, final OrderedNutrient orderedNutrient, - ) { - final String id = orderedNutrient.id; - if (id == _energyId) { - // we keep only kj and kcal - return null; - } - final double? value100g = _getValue(id, false); - final double? valueServing = _getValue(id, true); - if (value100g == null && - valueServing == null && - !orderedNutrient.important) { - return null; - } - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: _columnSize1, - child: _getNutrientCell( - appLocalizations, - orderedNutrient, - _servingOr100g, + ) => + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: _columnSize1, + child: _getNutrientCell( + appLocalizations, + orderedNutrient, + _servingOr100g, + ), ), - ), - SizedBox( - width: _columnSize2, - child: _getUnitCell(id), - ), - ], - ); - } + SizedBox( + width: _columnSize2, + child: _getUnitCell(orderedNutrient), + ), + ], + ); Widget _getNutrientCell( final AppLocalizations appLocalizations, final OrderedNutrient orderedNutrient, final bool perServing, ) { - final String id = orderedNutrient.id; - final String tag = _getNutrientServingTag(id, perServing); + final String valueKey = NutritionContainer.getValueKey( + orderedNutrient.id, + perServing, + ); final TextEditingController controller; - if (_controllers[tag] != null) { - controller = _controllers[tag]!; + if (_controllers[valueKey] != null) { + controller = _controllers[valueKey]!; } else { - double? value = _getValue(id, perServing); - final Unit? defaultNotWeightUnit = _getDefaultNotWeightUnit(id); - final Unit unit = _getUnit(id) ?? defaultNotWeightUnit ?? Unit.G; - if (value == null) { - if (id == _energyKJId || id == _energyKCalId) { - final double? valueEnergy = _getValue(_energyId, perServing); - final Unit? unitEnergy = _getUnit(_energyId); - if (id == _energyKJId) { - if (unitEnergy == Unit.KJ) { - value = valueEnergy; - } - } else if (id == _energyKCalId) { - if (unitEnergy == Unit.KCAL) { - value = valueEnergy; - } - } - } - } - value = _convertValueFromG(value, unit); + final double? value = _nutritionContainer.getValue(valueKey); controller = TextEditingController(); controller.text = value == null ? '' : _numberFormat.format(value); - _controllers[tag] = controller; + _controllers[valueKey] = controller; } return TextFormField( controller: controller, @@ -227,24 +169,24 @@ class _NutritionPageLoadedState extends State { ); } - Widget _getUnitCell(final String nutrientId) { - final Unit? defaultNotWeightUnit = _getDefaultNotWeightUnit(nutrientId); - final bool isWeight = defaultNotWeightUnit == null; - final Unit unit; - final String tag = _getNutrientIdFromServingTag(nutrientId); - if (_units[tag] != null) { - unit = _units[tag]!; - } else { - unit = _getUnit(nutrientId) ?? defaultNotWeightUnit ?? Unit.G; - _units[tag] = unit; - } + static const Map _unitLabels = { + Unit.G: 'g', + Unit.MILLI_G: 'mg', + Unit.MICRO_G: 'mcg/µg', + Unit.KJ: 'kJ', + Unit.KCAL: 'kcal', + Unit.PERCENT: '%', + }; + + static String _getUnitLabel(final Unit unit) => + _unitLabels[unit] ?? UnitHelper.unitToString(unit)!; + + Widget _getUnitCell(final OrderedNutrient orderedNutrient) { + final Unit unit = _nutritionContainer.getUnit(orderedNutrient.id); return ElevatedButton( - onPressed: isWeight + onPressed: NutritionContainer.isEditableWeight(orderedNutrient) ? () => setState( - () => _setUnit( - nutrientId, - _units[nutrientId] = _nextWeightUnits[unit]!, - ), + () => _nutritionContainer.setNextWeightUnit(orderedNutrient), ) : null, child: Text( @@ -256,8 +198,8 @@ class _NutritionPageLoadedState extends State { Widget _getServingField(final AppLocalizations appLocalizations) { final TextEditingController controller = TextEditingController(); - controller.text = widget.product.servingSize ?? ''; - _controllers[_fakeNutrientIdServingSize] = controller; + controller.text = _nutritionContainer.servingSize ?? ''; + _controllers[NutritionContainer.fakeNutrientIdServingSize] = controller; return Padding( padding: const EdgeInsets.only(bottom: VERY_LARGE_SPACE), child: TextFormField( @@ -286,80 +228,6 @@ class _NutritionPageLoadedState extends State { ], ); - Unit? _getUnit(final String nutrientId) => UnitHelper.stringToUnit( - _values[_getUnitValueTag(nutrientId)] as String?, - ); - - double? _getValue(final String nutrientId, final bool perServing) => - JsonObject.parseDouble( - _values[_getNutrientServingTag(nutrientId, perServing)]); - - void _initValues(final String nutrientId) => - _values[_getNutrientServingTag(nutrientId, true)] = - _values[_getNutrientServingTag(nutrientId, false)] = 0; - - void _setUnit(final String nutrientId, final Unit unit) => - _values[_getUnitValueTag(nutrientId)] = UnitHelper.unitToString(unit); - - String _getUnitValueTag(final String nutrientId) => '${nutrientId}_unit'; - - // note: 'energy-kcal' is directly for serving (no 'energy-kcal_serving') - String _getNutrientServingTag( - final String nutrientId, - final bool perServing, - ) => - nutrientId == _energyKCalId && perServing - ? _energyKCalId - : '$nutrientId${perServing ? '_serving' : '_100g'}'; - - String _getNutrientIdFromServingTag(final String key) => - key.replaceAll('_100g', '').replaceAll('_serving', ''); - - String _getUnitLabel(final Unit unit) => - _unitLabels[unit] ?? UnitHelper.unitToString(unit)!; - - // For the moment we only care about "weight or not weight?" - static const Map _defaultNotWeightUnits = { - _energyKJId: Unit.KJ, - _energyKCalId: Unit.KCAL, - 'alcohol': Unit.PERCENT, - 'cocoa': Unit.PERCENT, - 'collagen-meat-protein-ratio': Unit.PERCENT, - 'fruits-vegetables-nuts': Unit.PERCENT, - 'fruits-vegetables-nuts-dried': Unit.PERCENT, - 'fruits-vegetables-nuts-estimate': Unit.PERCENT, - }; - - // TODO(monsieurtanuki): could be refined with values taken from https://static.openfoodfacts.org/data/taxonomies/nutrients.json - Unit? _getDefaultNotWeightUnit(final String nutrientId) => - _defaultNotWeightUnits[nutrientId]; - - double? _convertValueFromG(final double? value, final Unit unit) { - if (value == null) { - return null; - } - if (unit == Unit.MILLI_G) { - return value * 1E3; - } - if (unit == Unit.MICRO_G) { - return value * 1E6; - } - return value; - } - - double? _convertValueToG(final double? value, final Unit unit) { - if (value == null) { - return null; - } - if (unit == Unit.MILLI_G) { - return value / 1E3; - } - if (unit == Unit.MICRO_G) { - return value / 1E6; - } - return value; - } - Widget _switchNoNutrition(final AppLocalizations appLocalizations) => Container( color: Theme.of(context).colorScheme.primary, @@ -388,25 +256,16 @@ class _NutritionPageLoadedState extends State { Widget _addNutrientButton(final AppLocalizations appLocalizations) => ElevatedButton.icon( onPressed: () async { - final List availables = []; - for (final OrderedNutrient orderedNutrient in _displayableList) { - final String id = orderedNutrient.id; - final double? value100g = _getValue(id, false); - final double? valueServing = _getValue(id, true); - final bool addAble = value100g == null && - valueServing == null && - !orderedNutrient.important; - if (addAble) { - availables.add(orderedNutrient); - } - } - availables.sort((final OrderedNutrient a, final OrderedNutrient b) => + final List leftovers = List.from( + _nutritionContainer.getLeftoverNutrients(), + ); + leftovers.sort((final OrderedNutrient a, final OrderedNutrient b) => a.name!.compareTo(b.name!)); final OrderedNutrient? selected = await showDialog( context: context, builder: (BuildContext context) { final List children = []; - for (final OrderedNutrient nutrient in availables) { + for (final OrderedNutrient nutrient in leftovers) { children.add( ListTile( title: Text(nutrient.name!), @@ -431,7 +290,7 @@ class _NutritionPageLoadedState extends State { ); }); if (selected != null) { - setState(() => _initValues(selected.id)); + setState(() => _nutritionContainer.add(selected)); } }, icon: const Icon(Icons.add), @@ -463,24 +322,10 @@ class _NutritionPageLoadedState extends State { ); Future _save(final LocalDatabase localDatabase) async { - final Map map = {}; - String? servingSize; for (final String key in _controllers.keys) { final TextEditingController controller = _controllers[key]!; - final String text = controller.text; - if (key == _fakeNutrientIdServingSize) { - servingSize = text; - } else { - if (text.isNotEmpty) { - final String nutrientId = _getNutrientIdFromServingTag(key); - final Unit unit = _units[nutrientId]!; - map[_getUnitValueTag(nutrientId)] = UnitHelper.unitToString(unit); - map[key] = _convertValueToG( - _numberFormat.parse(text).toDouble(), unit); // careful with comma - } - } + _nutritionContainer.setControllerText(key, controller.text); } - final Nutriments nutriments = Nutriments.fromJson(map); // minimal product: we only want to save the nutrients final Product inputProduct = Product( barcode: widget.product.barcode, diff --git a/packages/smooth_app/lib/pages/product/tmp_to_off_nutriments.dart b/packages/smooth_app/lib/pages/product/tmp_to_off_nutriments.dart new file mode 100644 index 00000000000..9c8af6d40bf --- /dev/null +++ b/packages/smooth_app/lib/pages/product/tmp_to_off_nutriments.dart @@ -0,0 +1,69 @@ +// TODO(monsieurtanuki): move to off-dart +class TmpToOffNutriments { + /// Nutrient ids supported by [Nutriments]. + /// + /// To be used when another source of nutrients (e.g. [OrderedNutrient]) + /// has a broader list of nutrients - that we simply could not handle with + /// [Nutriments]. + static const Set supportedNutrientIds = { + 'salt', + 'fiber', + 'sugars', + 'fat', + 'saturated-fat', + 'proteins', + 'nova-group', + 'energy', + 'energy-kcal', + 'carbohydrates', + 'caffeine', + 'calcium', + 'iron', + 'vitamin-c', + 'magnesium', + 'phosphorus', + 'potassium', + 'sodium', + 'zinc', + 'copper', + 'selenium', + 'vitamin-a', + 'vitamin-e', + 'vitamin-d', + 'vitamin-b1', + 'vitamin-b2', + 'vitamin-pp', + 'vitamin-b6', + 'vitamin-b12', + 'vitamin-b9', + 'vitamin-k', + 'cholesterol', + 'butyric-acid', + 'caproic-acid', + 'caprylic-acid', + 'capric-acid', + 'lauric-acid', + 'myristic-acid', + 'palmitic-acid', + 'stearic-acid', + 'oleic-acid', + 'linoleic-acid', + 'docosahexaenoic-acid', + 'eicosapentaenoic-acid', + 'erucic-acid', + 'monounsaturated', + 'polyunsaturated', + 'alcohol', + 'pantothenic-acid', + 'biotin', + 'chloride', + 'chromium', + 'fluoride', + 'iodine', + 'manganese', + 'molybdenum', + 'omega-3-fat', + 'omega-6-fat', + 'trans-fat', + }; +} diff --git a/packages/smooth_app/lib/pages/scan/scan_header.dart b/packages/smooth_app/lib/pages/scan/scan_header.dart new file mode 100644 index 00000000000..b2b7903089f --- /dev/null +++ b/packages/smooth_app/lib/pages/scan/scan_header.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_app/data_models/continuous_scan_model.dart'; +import 'package:smooth_app/helpers/ui_helpers.dart'; +import 'package:smooth_app/pages/scan/scan_page_helper.dart'; +import 'package:smooth_app/widgets/ranking_floating_action_button.dart'; + +class ScanHeader extends StatelessWidget { + const ScanHeader({Key? key}) : super(key: key); + + static const Duration _duration = Duration(milliseconds: 50); + static const double _visibleOpacity = 0.8; + static const double _invisibleOpacity = 0.0; + + @override + Widget build(BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context)!; + final ContinuousScanModel model = context.watch(); + + final ButtonStyle buttonStyle = ButtonStyle( + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.0), + ), + ), + ); + + return AnimatedOpacity( + opacity: + model.getBarcodes().isNotEmpty ? _visibleOpacity : _invisibleOpacity, + duration: _duration, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: VERY_SMALL_SPACE, + horizontal: MEDIUM_SPACE, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton.icon( + style: buttonStyle, + icon: const Icon(Icons.cancel_outlined), + onPressed: model.clearScanSession, + label: Text(appLocalizations.clear), + ), + ElevatedButton.icon( + style: buttonStyle, + icon: const Icon(RankingFloatingActionButton.rankingIconData), + onPressed: () => openPersonalizedRankingPage(context), + label: Text( + appLocalizations.plural_compare_x_products( + model.getBarcodes().length, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/smooth_app/lib/pages/scan/scan_page.dart b/packages/smooth_app/lib/pages/scan/scan_page.dart index 1a465c11244..cde74ff46c7 100644 --- a/packages/smooth_app/lib/pages/scan/scan_page.dart +++ b/packages/smooth_app/lib/pages/scan/scan_page.dart @@ -99,7 +99,6 @@ class _ScanPageState extends State { child: Scaffold( body: ScannerOverlay( child: child, - model: _model!, ), ), ); diff --git a/packages/smooth_app/lib/pages/scan/scan_page_helper.dart b/packages/smooth_app/lib/pages/scan/scan_page_helper.dart index 0393e55447f..8ee2aff9ee9 100644 --- a/packages/smooth_app/lib/pages/scan/scan_page_helper.dart +++ b/packages/smooth_app/lib/pages/scan/scan_page_helper.dart @@ -1,14 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; -import 'package:smooth_app/helpers/ui_helpers.dart'; import 'package:smooth_app/pages/personalized_ranking_page.dart'; import 'package:smooth_app/pages/product/common/product_query_page_helper.dart'; -import 'package:smooth_app/widgets/ranking_floating_action_button.dart'; - -bool areButtonsRendered(ContinuousScanModel model) => - model.hasMoreThanOneProduct; Future openPersonalizedRankingPage(BuildContext context) async { final ContinuousScanModel model = context.read(); @@ -27,45 +21,3 @@ Future openPersonalizedRankingPage(BuildContext context) async { ); await model.refresh(); } - -Widget buildButtonsRow(BuildContext context, ContinuousScanModel model) { - final AppLocalizations appLocalizations = AppLocalizations.of(context)!; - final ButtonStyle buttonStyle = ButtonStyle( - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18.0), - ), - ), - ); - return AnimatedOpacity( - opacity: areButtonsRendered(model) ? 0.8 : 0.0, - duration: const Duration(milliseconds: 50), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: VERY_SMALL_SPACE, - horizontal: MEDIUM_SPACE, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton.icon( - style: buttonStyle, - icon: const Icon(Icons.cancel_outlined), - onPressed: model.clearScanSession, - label: Text(appLocalizations.clear), - ), - ElevatedButton.icon( - style: buttonStyle, - icon: const Icon(RankingFloatingActionButton.rankingIconData), - onPressed: () => openPersonalizedRankingPage(context), - label: Text( - appLocalizations.plural_compare_x_products( - model.getBarcodes().length, - ), - ), - ), - ], - ), - ), - ); -} diff --git a/packages/smooth_app/lib/pages/scan/scanner_overlay.dart b/packages/smooth_app/lib/pages/scan/scanner_overlay.dart index d1c6c6a0c33..cf8759454fa 100644 --- a/packages/smooth_app/lib/pages/scan/scanner_overlay.dart +++ b/packages/smooth_app/lib/pages/scan/scanner_overlay.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/generic_lib/animations/smooth_reveal_animation.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_view_finder.dart'; -import 'package:smooth_app/pages/scan/scan_page_helper.dart'; +import 'package:smooth_app/pages/scan/scan_header.dart'; import 'package:smooth_app/widgets/smooth_product_carousel.dart'; /// This builds all the essential widgets which are displayed above the camera @@ -11,11 +12,9 @@ import 'package:smooth_app/widgets/smooth_product_carousel.dart'; class ScannerOverlay extends StatelessWidget { const ScannerOverlay({ required this.child, - required this.model, }); final Widget child; - final ContinuousScanModel model; static const double carouselHeightPct = 0.55; static const double scannerWidthPct = 0.6; @@ -24,6 +23,7 @@ class ScannerOverlay extends StatelessWidget { @override Widget build(BuildContext context) { + final ContinuousScanModel model = context.watch(); return LayoutBuilder( builder: ( BuildContext context, @@ -36,8 +36,9 @@ class ScannerOverlay extends StatelessWidget { ); final double carouselHeight = constraints.maxHeight * ScannerOverlay.carouselHeightPct; - final double buttonRowHeight = - areButtonsRendered(model) ? ScannerOverlay.buttonRowHeightPx : 0; + final double buttonRowHeight = model.getBarcodes().isNotEmpty + ? ScannerOverlay.buttonRowHeightPx + : 0; final double availableScanHeight = constraints.maxHeight - carouselHeight - buttonRowHeight; @@ -83,7 +84,7 @@ class ScannerOverlay extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - SafeArea(top: true, child: buildButtonsRow(context, model)), + const SafeArea(top: true, child: ScanHeader()), const Spacer(), SmoothProductCarousel( showSearchCard: true, diff --git a/packages/smooth_app/lib/pages/scan/search_page.dart b/packages/smooth_app/lib/pages/scan/search_page.dart index b688226c070..e0b25fc3e24 100644 --- a/packages/smooth_app/lib/pages/scan/search_page.dart +++ b/packages/smooth_app/lib/pages/scan/search_page.dart @@ -5,6 +5,7 @@ import 'package:smooth_app/data_models/fetched_product.dart'; import 'package:smooth_app/database/dao_string_list.dart'; import 'package:smooth_app/database/keywords_product_query.dart'; import 'package:smooth_app/database/local_database.dart'; +import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/pages/product/common/product_dialog_helper.dart'; import 'package:smooth_app/pages/product/common/product_query_page_helper.dart'; import 'package:smooth_app/pages/product/new_product_page.dart'; @@ -46,6 +47,12 @@ Future _onSubmittedBarcode( final FetchedProduct fetchedProduct = await productDialogHelper.openBestChoice(); if (fetchedProduct.status == FetchedProductStatus.ok) { + AnalyticsHelper.trackSearch( + search: value, + searchCategory: 'barcode', + searchCount: 1, + ); + Navigator.push( context, MaterialPageRoute( @@ -53,6 +60,11 @@ Future _onSubmittedBarcode( ), ); } else { + AnalyticsHelper.trackSearch( + search: value, + searchCategory: 'barcode', + searchCount: 0, + ); productDialogHelper.openError(fetchedProduct); } } diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index 2689db18b0b..df758fc078e 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -232,13 +232,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" - fk_user_agent: - dependency: transitive - description: - name: fk_user_agent - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" flutter: dependency: "direct main" description: flutter @@ -518,13 +511,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" - matomo: + matomo_forever: dependency: "direct main" description: - name: matomo + name: matomo_forever url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.0.0+1" meta: dependency: transitive description: @@ -957,13 +950,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.1" - universal_html: - dependency: transitive - description: - name: universal_html - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" universal_io: dependency: transitive description: diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index c94b3eaa5c1..a508d6a2d47 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: image_picker: ^0.8.4+8 iso_countries: 2.1.0 latlong2: ^0.8.1 - matomo: ^1.1.0 + matomo_forever: ^1.0.0+1 modal_bottom_sheet: ^2.0.0 openfoodfacts: ^1.12.0 # openfoodfacts: