Files
smooth-app/packages/smooth_app/lib/helpers/analytics_helper.dart
2025-07-10 11:38:43 +02:00

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,
}