mirror of
https://github.com/openfoodfacts/smooth-app.git
synced 2025-08-06 18:25:11 +08:00
458 lines
14 KiB
Dart
458 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:matomo_tracker/matomo_tracker.dart';
|
|
import 'package:openfoodfacts/openfoodfacts.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
import 'package:smooth_app/data_models/preferences/user_preferences.dart';
|
|
import 'package:smooth_app/helpers/global_vars.dart';
|
|
import 'package:smooth_app/query/product_query.dart';
|
|
|
|
/// Category for Matomo Events
|
|
enum AnalyticsCategory {
|
|
userManagement(tag: 'user management'),
|
|
scanning(tag: 'scanning'),
|
|
share(tag: 'share'),
|
|
loadingProduct(tag: 'loading product'),
|
|
couldNotFindProduct(tag: 'could not find product'),
|
|
productEdit(tag: 'product edit'),
|
|
productFastTrackEdit(tag: 'product fast track edit'),
|
|
newProduct(tag: 'new product'),
|
|
robotoff(tag: 'robotoff'),
|
|
list(tag: 'list'),
|
|
deepLink(tag: 'deep link'),
|
|
hungerGame(tag: 'hunger game'),
|
|
appRating(tag: 'app rating');
|
|
|
|
const AnalyticsCategory({required this.tag});
|
|
|
|
final String tag;
|
|
}
|
|
|
|
/// Event types for Matomo analytics
|
|
enum AnalyticsEvent {
|
|
scanAction(tag: 'scanned product', category: AnalyticsCategory.scanning),
|
|
obsoleteProduct(
|
|
tag: 'obsolete product',
|
|
category: AnalyticsCategory.scanning,
|
|
),
|
|
shareProduct(tag: 'shared product', category: AnalyticsCategory.share),
|
|
loginAction(tag: 'logged in', category: AnalyticsCategory.userManagement),
|
|
registerAction(tag: 'register', category: AnalyticsCategory.userManagement),
|
|
logoutAction(tag: 'logged out', category: AnalyticsCategory.userManagement),
|
|
producerSignup(
|
|
tag: 'signed up as producer',
|
|
category: AnalyticsCategory.userManagement,
|
|
),
|
|
couldNotScanProduct(
|
|
tag: 'could not scan product',
|
|
category: AnalyticsCategory.couldNotFindProduct,
|
|
),
|
|
couldNotFindProduct(
|
|
tag: 'could not find product',
|
|
category: AnalyticsCategory.couldNotFindProduct,
|
|
),
|
|
ignoreProductLoading(
|
|
tag: 'ignore product',
|
|
category: AnalyticsCategory.loadingProduct,
|
|
),
|
|
restartProductLoading(
|
|
tag: 'restart request',
|
|
category: AnalyticsCategory.loadingProduct,
|
|
),
|
|
ignoreProductNotFound(
|
|
tag: 'ignore product',
|
|
category: AnalyticsCategory.couldNotFindProduct,
|
|
),
|
|
openProductEditPage(
|
|
tag: 'opened product edit page',
|
|
category: AnalyticsCategory.productEdit,
|
|
),
|
|
openFastTrackProductEditPage(
|
|
tag: 'opened fast-track product edit page',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
showFastTrackProductEditCard(
|
|
tag: 'showed fast-track product edit card',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
notShowFastTrackProductEditCardNutriscore(
|
|
tag: 'nutriscore not applicable - no fast-track product edit card',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
notShowFastTrackProductEditCardEnvironmentalScore(
|
|
tag: 'ecoscore not applicable - no fast-track product edit card',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
categoriesFastTrackProductPage(
|
|
tag: 'set categories on fast track product page',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
nutritionFastTrackProductPage(
|
|
tag: 'set nutrition facts on fast track product page',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
ingredientsFastTrackProductPage(
|
|
tag: 'set ingredients on fast track product page',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
closeEmptyFastTrackProductPage(
|
|
tag: 'closed new product page without any input',
|
|
category: AnalyticsCategory.productFastTrackEdit,
|
|
),
|
|
openNewProductPage(
|
|
tag: 'opened new product page',
|
|
category: AnalyticsCategory.newProduct,
|
|
),
|
|
categoriesNewProductPage(
|
|
tag: 'set categories on new product page',
|
|
category: AnalyticsCategory.newProduct,
|
|
),
|
|
nutritionNewProductPage(
|
|
tag: 'set nutrition facts on new product page',
|
|
category: AnalyticsCategory.newProduct,
|
|
),
|
|
ingredientsNewProductPage(
|
|
tag: 'set ingredients on new product page',
|
|
category: AnalyticsCategory.newProduct,
|
|
),
|
|
imagesNewProductPage(
|
|
tag: 'set at least one image on new product page',
|
|
category: AnalyticsCategory.newProduct,
|
|
),
|
|
closeEmptyNewProductPage(
|
|
tag: 'closed new product page without any input',
|
|
category: AnalyticsCategory.newProduct,
|
|
),
|
|
shareList(tag: 'shared a list', category: AnalyticsCategory.list),
|
|
openListWeb(tag: 'open a list in wbe', category: AnalyticsCategory.list),
|
|
productDeepLink(
|
|
tag: 'open a product from an URL',
|
|
category: AnalyticsCategory.deepLink,
|
|
),
|
|
genericDeepLink(
|
|
tag: 'generic deep link',
|
|
category: AnalyticsCategory.deepLink,
|
|
),
|
|
questionVisible(
|
|
tag: 'question visible',
|
|
category: AnalyticsCategory.robotoff,
|
|
),
|
|
questionClicked(
|
|
tag: 'question clicked',
|
|
category: AnalyticsCategory.robotoff,
|
|
),
|
|
hungerGameOpened(
|
|
tag: 'hunger game opened',
|
|
category: AnalyticsCategory.hungerGame,
|
|
),
|
|
appRatingSatisfied(tag: 'satisfied', category: AnalyticsCategory.appRating),
|
|
appRatingNeutral(tag: 'neutral', category: AnalyticsCategory.appRating),
|
|
appRatingNotSatisfied(
|
|
tag: 'not satisfied',
|
|
category: AnalyticsCategory.appRating,
|
|
);
|
|
|
|
const AnalyticsEvent({required this.tag, required this.category});
|
|
|
|
final String tag;
|
|
final AnalyticsCategory category;
|
|
}
|
|
|
|
enum AnalyticsRobotoffEvents {
|
|
robotoffNutritionExtracted(name: 'robotoff nutrition extracted'),
|
|
robotoffNutritionInsightAccepted(name: 'robotoff nutrition insight accepted'),
|
|
robotoffNutritionInsightRejected(name: 'robotoff nutrition insight rejected');
|
|
|
|
const AnalyticsRobotoffEvents({required this.name});
|
|
|
|
final String name;
|
|
}
|
|
|
|
enum AnalyticsEditEvents {
|
|
basicDetails(name: 'BasicDetails'),
|
|
photos(name: 'Photos'),
|
|
powerEditScreen(name: 'Power Edit Screen'),
|
|
ingredients_and_Origins(name: 'Ingredient And Origins'),
|
|
categories(name: 'Categories'),
|
|
traces(name: 'Traces'),
|
|
nutrition_Facts(name: 'Nutrition Facts'),
|
|
labelsAndCertifications(name: 'Labels And Certifications'),
|
|
packagingComponents(name: 'Packaging Components'),
|
|
recyclingInstructionsPhotos(name: 'Recycling Instructions Photos'),
|
|
stores(name: 'Stores'),
|
|
origins(name: 'Origins'),
|
|
traceabilityCodes(name: 'Traceability Codes'),
|
|
country(name: 'Country'),
|
|
otherDetails(name: 'Other Details');
|
|
|
|
const AnalyticsEditEvents({required this.name});
|
|
|
|
final String name;
|
|
}
|
|
|
|
/// 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 bool _crashReports = false;
|
|
static _AnalyticsTrackingMode _analyticsReporting =
|
|
_AnalyticsTrackingMode.disabled;
|
|
|
|
static String latestSearch = '';
|
|
|
|
static late int _uniqueRandom;
|
|
|
|
static Future<void> linkPreferences(UserPreferences userPreferences) async {
|
|
// Init the value
|
|
_setAnalyticsReports(userPreferences.onAnalyticsChanged.value);
|
|
_setCrashReports(userPreferences.onCrashReportingChanged.value);
|
|
|
|
// Listen to changes
|
|
userPreferences.onAnalyticsChanged.addListener(() {
|
|
_setAnalyticsReports(userPreferences.onAnalyticsChanged.value);
|
|
});
|
|
|
|
userPreferences.onCrashReportingChanged.addListener(() {
|
|
_setCrashReports(userPreferences.onCrashReportingChanged.value);
|
|
});
|
|
|
|
_uniqueRandom = await userPreferences.getUniqueRandom();
|
|
}
|
|
|
|
static Future<void> initSentry({required Function()? appRunner}) async {
|
|
await SentryFlutter.init((SentryOptions options) {
|
|
options
|
|
..dsn =
|
|
'https://22ec5d0489534b91ba455462d3736680@o241488.ingest.sentry.io/5376745'
|
|
..beforeSend = (SentryEvent event, Hint hint) async {
|
|
return event
|
|
..tags = <String, String>{
|
|
'store': GlobalVars.storeLabel.name,
|
|
'scanner': GlobalVars.scannerLabel.name,
|
|
};
|
|
};
|
|
// To set a uniform sample rate
|
|
options
|
|
..tracesSampleRate = 1.0
|
|
..beforeSend = _beforeSend
|
|
..captureFailedRequests = false
|
|
..environment =
|
|
'${GlobalVars.storeLabel.name}-${GlobalVars.scannerLabel.name}';
|
|
}, appRunner: appRunner);
|
|
}
|
|
|
|
/// Don't call this method directly, it is automatically updated via the
|
|
/// [UserPreferences]
|
|
static void _setCrashReports(final bool crashReports) =>
|
|
_crashReports = crashReports;
|
|
|
|
/// Don't call this method directly, it is automatically updated via the
|
|
/// [UserPreferences]
|
|
static Future<void> _setAnalyticsReports(final bool allow) async {
|
|
if (allow) {
|
|
_analyticsReporting = _AnalyticsTrackingMode.enabled;
|
|
} else {
|
|
_analyticsReporting = _AnalyticsTrackingMode.anonymous;
|
|
}
|
|
|
|
if (MatomoTracker.instance.initialized) {
|
|
MatomoTracker.instance.setVisitorUserId(_visitorId);
|
|
}
|
|
}
|
|
|
|
/// Returns true if analytics reporting is enabled.
|
|
static bool get isEnabled =>
|
|
_analyticsReporting == _AnalyticsTrackingMode.enabled;
|
|
|
|
static FutureOr<SentryEvent?> _beforeSend(
|
|
SentryEvent event,
|
|
dynamic hint,
|
|
) async {
|
|
if (!_crashReports) {
|
|
return null;
|
|
}
|
|
return event;
|
|
}
|
|
|
|
static late PackageInfo _packageInfo;
|
|
|
|
static Future<void> initMatomo(final bool screenshotMode) async {
|
|
_packageInfo = await PackageInfo.fromPlatform();
|
|
if (screenshotMode) {
|
|
_setCrashReports(false);
|
|
_setAnalyticsReports(false);
|
|
return;
|
|
}
|
|
try {
|
|
await MatomoTracker.instance.initialize(
|
|
url: 'https://analytics.openfoodfacts.org/matomo.php',
|
|
siteId: '2',
|
|
visitorId: _visitorId,
|
|
);
|
|
} catch (err) {
|
|
// With Hot Reload, this may trigger a late field already initialized
|
|
}
|
|
}
|
|
|
|
/// A visitor id should have a length of 16 characters.
|
|
static String? get _visitorId {
|
|
// if user opts out then track anonymously with userId containing zeros
|
|
if (kDebugMode) {
|
|
return 'smoothie_debug--';
|
|
}
|
|
|
|
switch (_analyticsReporting) {
|
|
case _AnalyticsTrackingMode.anonymous:
|
|
return _anonymousVisitorId;
|
|
case _AnalyticsTrackingMode.disabled:
|
|
return '';
|
|
case _AnalyticsTrackingMode.enabled:
|
|
return OpenFoodAPIConfiguration.uuid;
|
|
}
|
|
}
|
|
|
|
/// Returns a unique visitor id that starts with a letter between A and Z.
|
|
static String get _anonymousVisitorId => _uniqueLetter + ('0' * 15);
|
|
|
|
/// Returns a letter between A and Z, depending on [_uniqueRandom].
|
|
static String get _uniqueLetter =>
|
|
String.fromCharCode('A'.codeUnitAt(0) + _uniqueRandom % 26);
|
|
|
|
static void trackEvent(
|
|
AnalyticsEvent msg, {
|
|
int? eventValue,
|
|
String? barcode,
|
|
}) => trackCustomEvent(
|
|
msg.name,
|
|
msg.category.tag,
|
|
eventValue: eventValue,
|
|
barcode: barcode,
|
|
);
|
|
|
|
// Used by code which is outside of the core:smooth_app code
|
|
// e.g. the scanner implementation
|
|
static void trackCustomEvent(
|
|
String msg,
|
|
String category, {
|
|
int? eventValue,
|
|
String? barcode,
|
|
String? action,
|
|
ProductType? productType,
|
|
}) {
|
|
final Map<String, String> dimensions = <String, String>{
|
|
'dimension1': ProductQuery.getLanguage().offTag,
|
|
'dimension2': ProductQuery.getCountry().offTag,
|
|
'dimension3': ProductQuery.isLoggedIn() ? 'Y' : 'N',
|
|
'dimension4': _packageInfo.version,
|
|
'dimension5': productType?.offTag ?? '',
|
|
};
|
|
MatomoTracker.instance.trackEvent(
|
|
eventInfo: EventInfo(
|
|
name: msg,
|
|
category: category,
|
|
action: action ?? msg,
|
|
value: eventValue ?? _formatBarcode(barcode),
|
|
),
|
|
dimensions: dimensions,
|
|
);
|
|
}
|
|
|
|
static void trackRobotoffExtraction(
|
|
AnalyticsRobotoffEvents event,
|
|
Nutrient nutrient,
|
|
Product product,
|
|
) => trackCustomEvent(
|
|
event.name,
|
|
AnalyticsCategory.robotoff.tag,
|
|
action: nutrient.name,
|
|
barcode: product.barcode,
|
|
productType: product.productType ?? ProductType.food,
|
|
);
|
|
|
|
static void trackProductEdit(
|
|
AnalyticsEditEvents editEventName,
|
|
Product product, [
|
|
bool saved = false,
|
|
]) => trackCustomEvent(
|
|
saved ? '${editEventName.name}-saved' : editEventName.name,
|
|
AnalyticsCategory.productEdit.tag,
|
|
action: editEventName.name,
|
|
barcode: product.barcode,
|
|
productType: product.productType ?? ProductType.food,
|
|
);
|
|
|
|
static void trackProductEvent(
|
|
AnalyticsEvent msg, {
|
|
int? eventValue,
|
|
required Product product,
|
|
}) => trackCustomEvent(
|
|
msg.name,
|
|
msg.category.tag,
|
|
eventValue: eventValue,
|
|
barcode: product.barcode,
|
|
productType: product.productType ?? ProductType.food,
|
|
);
|
|
|
|
static void trackSearch({
|
|
required String search,
|
|
String? searchCategory,
|
|
int? searchCount,
|
|
}) {
|
|
final String searchString = '$search,$searchCategory,$searchCount';
|
|
|
|
if (searchString == latestSearch) {
|
|
return;
|
|
}
|
|
|
|
latestSearch = searchString;
|
|
|
|
MatomoTracker.instance.trackSearch(
|
|
searchKeyword: search,
|
|
searchCount: searchCount,
|
|
searchCategory: searchCategory,
|
|
);
|
|
}
|
|
|
|
static void trackOutlink({required String url}) =>
|
|
MatomoTracker.instance.trackOutlink(link: url);
|
|
|
|
static int? _formatBarcode(String? barcode) {
|
|
if (barcode == null) {
|
|
return null;
|
|
}
|
|
|
|
const int fallback = 000000000;
|
|
try {
|
|
return int.tryParse(barcode) ?? fallback;
|
|
} on FormatException {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
static void sendException(dynamic throwable, {dynamic stackTrace}) {
|
|
Sentry.captureException(throwable, stackTrace: stackTrace);
|
|
}
|
|
|
|
static String? get matomoVisitorId => MatomoTracker.instance.visitor.id;
|
|
}
|
|
|
|
enum _AnalyticsTrackingMode {
|
|
// With the user consent
|
|
enabled,
|
|
// Without the user consent
|
|
anonymous,
|
|
// On F-Droid builds
|
|
disabled,
|
|
}
|