import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; import 'package:smooth_app/pages/onboarding/onboarding_flow_navigator.dart'; import 'package:smooth_app/pages/preferences/user_preferences_dev_mode.dart'; import 'package:smooth_app/pages/product/product_page/footer/new_product_footer.dart'; import 'package:smooth_app/themes/color_schemes.dart'; import 'package:smooth_app/themes/theme_provider.dart'; part 'package:smooth_app/data_models/preferences/migration/user_preferences_migration.dart'; /// User choice regarding the picture source. enum UserPictureSource { /// Always select between Gallery and Camera SELECT('S'), /// Always use Gallery GALLERY('G'), /// Always use Camera CAMERA('C'); const UserPictureSource(this.tag); final String tag; static UserPictureSource get defaultValue => UserPictureSource.SELECT; static UserPictureSource fromString(final String tag) => UserPictureSource .values .firstWhere((final UserPictureSource source) => source.tag == tag); } class UserPreferences extends ChangeNotifier { UserPreferences._shared(final SharedPreferences sharedPreferences) : _sharedPreferences = sharedPreferences { onCrashReportingChanged = ValueNotifier(crashReports); onAnalyticsChanged = ValueNotifier(userTracking); _incrementAppLaunches(); } /// Singleton static UserPreferences? _instance; final SharedPreferences _sharedPreferences; static Future getUserPreferences() async { if (_instance == null) { final SharedPreferences preferences = await SharedPreferences.getInstance(); _instance = UserPreferences._shared(preferences); } return _instance!; } /// Once we initialized with main.dart, we don't need the "async". static UserPreferences getUserPreferencesSync() => _instance!; late ValueNotifier onCrashReportingChanged; late ValueNotifier onAnalyticsChanged; /// Whether the preferences are empty or not static const String _TAG_INIT = 'init'; /// The current version of preferences static const String _TAG_VERSION = 'prefs_version'; static const int _PREFS_CURRENT_VERSION = 3; static const String _TAG_APP_LAUNCHES = 'appLaunches'; static const String _TAG_PREFIX_IMPORTANCE = 'IMPORTANCE_AS_STRING'; static const String _TAG_CURRENT_THEME_MODE = 'currentThemeMode'; static const String _TAG_CURRENT_COLOR_SCHEME = 'currentColorScheme'; static const String _TAG_CURRENT_CONTRAST_MODE = 'contrastMode'; static const String _TAG_USER_COUNTRY_CODE = 'userCountry'; static const String _TAG_USER_COUNTRY_CODE_LAST_UPDATE = 'userCountryLastUpdate'; static const String _TAG_USER_CURRENCY_CODE = 'userCurrency'; static const String _TAG_LAST_VISITED_ONBOARDING_PAGE = 'lastVisitedOnboardingPage'; static const String _TAG_PREFIX_FLAG = 'FLAG_PREFIX_'; static const String _TAG_DEV_MODE = 'devMode'; static const String _TAG_USER_TRACKING = 'user_tracking'; static const String _TAG_CRASH_REPORTS = 'crash_reports'; static const String _TAG_PRICES_FEEDBACK_FORM = 'prices_feedback_form'; static const String _TAG_EXCLUDED_ATTRIBUTE_IDS = 'excluded_attributes'; static const String _TAG_UNIQUE_RANDOM = '_unique_random'; static const String _TAG_LAZY_COUNT_PREFIX = '_lazy_count_prefix'; static const String _TAG_LATEST_PRODUCT_TYPE = '_latest_product_type'; static const String _TAG_SEARCH_SHOW_PRODUCT_TYPE_FILTER = '_search_show_product_type_filter'; static const String _TAG_PRODUCT_PAGE_ACTIONS = '_product_page_actions'; static const String _TAG_LANGUAGES_USAGE = '_languages_usage'; static const String _TAG_PRODUCT_PAGE_TABS = '_product_page_tabs'; static const String _TAG_READY_FOR_PRICE_TAG_VALIDATION = 'ready_for_price_tag_validation'; /// Camera preferences // Use the flash/torch with the camera static const String _TAG_USE_FLASH_WITH_CAMERA = 'enable_flash_with_camera'; // Play sound when decoding a barcode static const String _TAG_PLAY_CAMERA_SCAN_SOUND = 'camera_scan_sound'; /// Vibrations / haptic feedback static const String _TAG_HAPTIC_FEEDBACK_IN_APP = 'haptic_feedback_enabled'; /// Price privacy warning static const String TAG_PRICE_PRIVACY_WARNING = 'price_privacy_warning'; /// Attribute group that is not collapsed static const String _TAG_ACTIVE_ATTRIBUTE_GROUP = 'activeAttributeGroup'; /// User picture source static const String _TAG_USER_PICTURE_SOURCE = 'userPictureSource'; /// If the in-app review was asked at least one time (false by default) static const String _TAG_IN_APP_REVIEW_ALREADY_DISPLAYED = 'inAppReviewAlreadyAsked'; static const String _TAG_NUMBER_OF_SCANS = 'numberOfScans'; /// User knowledge panel order static const String _TAG_USER_KNOWLEDGE_PANEL_ORDER = 'userKnowledgePanelOrder'; /// Tagline feed (news displayed / clicked) static const String _TAG_TAGLINE_FEED_NEWS_DISPLAYED = 'taglineFeedNewsDisplayed'; static const String _TAG_TAGLINE_FEED_NEWS_CLICKED = 'taglineFeedNewsClicked'; /// Info messages static const String _TAG_SHOW_BANNER_INPUT_PRODUCT_NAME = 'bannerInputProductName'; Future init(final ProductPreferences productPreferences) async { await _onMigrate(); if (_sharedPreferences.getBool(_TAG_INIT) != null) { return; } await productPreferences.resetImportances(); await _sharedPreferences.setBool(_TAG_INIT, true); } /// Allow to migrate between versions Future _onMigrate() async { await UserPreferencesMigrationTool.onUpgrade( this, _sharedPreferences.getInt(_TAG_VERSION), _PREFS_CURRENT_VERSION, ); await _sharedPreferences.setInt( _TAG_VERSION, UserPreferences._PREFS_CURRENT_VERSION, ); } int get appLaunches => _sharedPreferences.getInt(_TAG_APP_LAUNCHES) ?? 0; Future _incrementAppLaunches() async { await _sharedPreferences.setInt(_TAG_APP_LAUNCHES, appLaunches + 1); // No need to call notifyListeners here } String _getImportanceTag(final String variable) => _TAG_PREFIX_IMPORTANCE + variable; Future setImportance( final String attributeId, final String importanceId, ) async { await _sharedPreferences.setString( _getImportanceTag(attributeId), importanceId, ); notifyListeners(); } String getImportance(final String attributeId) => _sharedPreferences.getString(_getImportanceTag(attributeId)) ?? PreferenceImportance.ID_NOT_IMPORTANT; Future setTheme(final String theme) async { await _sharedPreferences.setString(_TAG_CURRENT_THEME_MODE, theme); notifyListeners(); } Future setColorScheme(final String color) async { await _sharedPreferences.setString(_TAG_CURRENT_COLOR_SCHEME, color); notifyListeners(); } Future setContrastScheme(final String contrastLevel) async { await _sharedPreferences.setString( _TAG_CURRENT_CONTRAST_MODE, contrastLevel, ); notifyListeners(); } String _getLazyCountTag(final String tag) => '$_TAG_LAZY_COUNT_PREFIX$tag'; Future setLazyCount( final int value, final String suffixTag, { required final bool notify, }) async { final int? oldValue = getLazyCount(suffixTag); if (value == oldValue) { return; } await _sharedPreferences.setInt(_getLazyCountTag(suffixTag), value); if (notify) { notifyListeners(); } } int? getLazyCount(final String suffixTag) => _sharedPreferences.getInt(_getLazyCountTag(suffixTag)); Future setUserTracking(final bool state) async { await _sharedPreferences.setBool(_TAG_USER_TRACKING, state); onAnalyticsChanged.value = state; notifyListeners(); } bool get userTracking => _sharedPreferences.getBool(_TAG_USER_TRACKING) ?? false; /// Returns a huge random value that will be computed just once. Future getUniqueRandom() async { const String tag = _TAG_UNIQUE_RANDOM; int? result = _sharedPreferences.getInt(tag); if (result != null) { return result; } result = math.Random().nextInt(1 << 32); await _sharedPreferences.setInt(tag, result); return result; } Future setCrashReports(final bool state) async { await _sharedPreferences.setBool(_TAG_CRASH_REPORTS, state); onCrashReportingChanged.value = state; notifyListeners(); } bool get crashReports => _sharedPreferences.getBool(_TAG_CRASH_REPORTS) ?? false; Future markPricesFeedbackFormAsCompleted() async { await _sharedPreferences.setBool(_TAG_PRICES_FEEDBACK_FORM, false); notifyListeners(); } bool get shouldShowPricesFeedbackForm => _sharedPreferences.getBool(_TAG_PRICES_FEEDBACK_FORM) ?? true; Future setReadyForPriceTagValidation(final bool state) async => _sharedPreferences.setBool(_TAG_READY_FOR_PRICE_TAG_VALIDATION, state); bool get readyForPriceTagValidation => _sharedPreferences.getBool(_TAG_READY_FOR_PRICE_TAG_VALIDATION) ?? false; String get currentTheme => _sharedPreferences.getString(_TAG_CURRENT_THEME_MODE) ?? THEME_SYSTEM_DEFAULT; String get currentColor => _sharedPreferences.getString(_TAG_CURRENT_COLOR_SCHEME) ?? COLOR_DEFAULT_NAME; String get currentContrastLevel => _sharedPreferences.getString(_TAG_CURRENT_CONTRAST_MODE) ?? CONTRAST_MEDIUM; /// Please use [ProductQuery.setCountry] as interface Future setUserCountryCode(final String countryCode) async { await _sharedPreferences.setString(_TAG_USER_COUNTRY_CODE, countryCode); await _sharedPreferences.setInt( _TAG_USER_COUNTRY_CODE_LAST_UPDATE, DateTime.now().millisecondsSinceEpoch, ); notifyListeners(); } String? get userCountryCode => _sharedPreferences.getString(_TAG_USER_COUNTRY_CODE); Future setUserCurrencyCode(final String code) async { await _sharedPreferences.setString(_TAG_USER_CURRENCY_CODE, code); notifyListeners(); } String? get userCurrencyCode => _sharedPreferences.getString(_TAG_USER_CURRENCY_CODE); Future setLastVisitedOnboardingPage(final OnboardingPage page) async { await _sharedPreferences.setInt( _TAG_LAST_VISITED_ONBOARDING_PAGE, page.index, ); notifyListeners(); } Future resetOnboarding() async { await setLastVisitedOnboardingPage(OnboardingPage.NOT_STARTED); // for tests with a fresh null country await _sharedPreferences.remove(_TAG_USER_COUNTRY_CODE); await _sharedPreferences.remove(_TAG_USER_CURRENCY_CODE); notifyListeners(); } OnboardingPage get lastVisitedOnboardingPage { final int? pageIndex = _sharedPreferences.getInt( _TAG_LAST_VISITED_ONBOARDING_PAGE, ); return pageIndex == null ? OnboardingPage.NOT_STARTED : OnboardingPage.values[math.min( pageIndex, OnboardingPage.values.length - 1, )]; } Future incrementScanCount() async { await _sharedPreferences.setInt(_TAG_NUMBER_OF_SCANS, numberOfScans + 1); notifyListeners(); } int get numberOfScans => _sharedPreferences.getInt(_TAG_NUMBER_OF_SCANS) ?? 0; Future markInAppReviewAsShown() async { await _sharedPreferences.setBool( _TAG_IN_APP_REVIEW_ALREADY_DISPLAYED, true, ); notifyListeners(); } bool get inAppReviewAlreadyAsked => _sharedPreferences.getBool(_TAG_IN_APP_REVIEW_ALREADY_DISPLAYED) ?? false; /// Please use [ProductQuery.setLanguage] as interface Future setAppLanguageCode(String? languageCode) async { if (languageCode == null) { await _sharedPreferences.remove( UserPreferencesDevMode.userPreferencesAppLanguageCode, ); } else { await setDevModeString( UserPreferencesDevMode.userPreferencesAppLanguageCode, languageCode, ); } notifyListeners(); } /// Please use [ProductQuery.getLanguage] as interface String? get appLanguageCode => getDevModeString(UserPreferencesDevMode.userPreferencesAppLanguageCode); String _getFlagTag(final String key) => _TAG_PREFIX_FLAG + key; Future setFlag(final String key, final bool? value) async { value == null ? await _sharedPreferences.remove(_getFlagTag(key)) : await _sharedPreferences.setBool(_getFlagTag(key), value); notifyListeners(); } bool? getFlag(final String key) => _sharedPreferences.getBool(_getFlagTag(key)); List getExcludedAttributeIds() => _sharedPreferences.getStringList(_TAG_EXCLUDED_ATTRIBUTE_IDS) ?? []; Future setExcludedAttributeIds(final List value) async { await _sharedPreferences.setStringList(_TAG_EXCLUDED_ATTRIBUTE_IDS, value); notifyListeners(); } bool get useFlashWithCamera => _sharedPreferences.getBool(_TAG_USE_FLASH_WITH_CAMERA) ?? false; Future setUseFlashWithCamera(final bool useFlash) async { await _sharedPreferences.setBool(_TAG_USE_FLASH_WITH_CAMERA, useFlash); notifyListeners(); } Future setPlayCameraSound(bool playSound) async { await _sharedPreferences.setBool(_TAG_PLAY_CAMERA_SCAN_SOUND, playSound); notifyListeners(); } bool get playCameraSound => _sharedPreferences.getBool(_TAG_PLAY_CAMERA_SCAN_SOUND) ?? false; Future setHapticFeedbackEnabled(bool enabled) async { await _sharedPreferences.setBool(_TAG_HAPTIC_FEEDBACK_IN_APP, enabled); notifyListeners(); } bool get hapticFeedbackEnabled => _sharedPreferences.getBool(_TAG_HAPTIC_FEEDBACK_IN_APP) ?? true; Future setDevMode(final int value) async { await _sharedPreferences.setInt(_TAG_DEV_MODE, value); notifyListeners(); } int get devMode => _sharedPreferences.getInt(_TAG_DEV_MODE) ?? 0; Future setDevModeString(final String tag, final String value) async { await _sharedPreferences.setString(tag, value); notifyListeners(); } String? getDevModeString(final String tag) => _sharedPreferences.getString(tag); Future setActiveAttributeGroup(final String value) async { await _sharedPreferences.setString(_TAG_ACTIVE_ATTRIBUTE_GROUP, value); notifyListeners(); } String get activeAttributeGroup => _sharedPreferences.getString(_TAG_ACTIVE_ATTRIBUTE_GROUP) ?? AttributeGroup.ATTRIBUTE_GROUP_NUTRITIONAL_QUALITY; UserPictureSource get userPictureSource => UserPictureSource.fromString( _sharedPreferences.getString(_TAG_USER_PICTURE_SOURCE) ?? UserPictureSource.defaultValue.tag, ); Future setUserPictureSource(final UserPictureSource source) async { await _sharedPreferences.setString(_TAG_USER_PICTURE_SOURCE, source.tag); notifyListeners(); } List get userKnowledgePanelOrder => _sharedPreferences.getStringList(_TAG_USER_KNOWLEDGE_PANEL_ORDER) ?? []; Future setUserKnowledgePanelOrder(final List source) async { await _sharedPreferences.setStringList( _TAG_USER_KNOWLEDGE_PANEL_ORDER, source, ); notifyListeners(); } List get taglineFeedDisplayedNews => _sharedPreferences.getStringList(_TAG_TAGLINE_FEED_NEWS_DISPLAYED) ?? []; List get taglineFeedClickedNews => _sharedPreferences.getStringList(_TAG_TAGLINE_FEED_NEWS_CLICKED) ?? []; // This method voluntarily does not notify listeners (not needed) Future taglineFeedMarkNewsAsDisplayed(final String ids) async { final List displayedNews = taglineFeedDisplayedNews; final List clickedNews = taglineFeedClickedNews; if (!displayedNews.contains(ids)) { displayedNews.add(ids); _sharedPreferences.setStringList( _TAG_TAGLINE_FEED_NEWS_DISPLAYED, displayedNews, ); } if (clickedNews.contains(ids)) { clickedNews.remove(ids); _sharedPreferences.setStringList( _TAG_TAGLINE_FEED_NEWS_CLICKED, clickedNews, ); } } // This method voluntarily does not notify listeners (not needed) Future taglineFeedMarkNewsAsClicked(final String ids) async { final List displayedNews = taglineFeedDisplayedNews; final List clickedNews = taglineFeedClickedNews; if (displayedNews.contains(ids)) { displayedNews.remove(ids); _sharedPreferences.setStringList( _TAG_TAGLINE_FEED_NEWS_DISPLAYED, displayedNews, ); } if (!clickedNews.contains(ids)) { clickedNews.add(ids); _sharedPreferences.setStringList( _TAG_TAGLINE_FEED_NEWS_CLICKED, clickedNews, ); } } bool showInputProductNameBanner() => _sharedPreferences.getBool(_TAG_SHOW_BANNER_INPUT_PRODUCT_NAME) ?? true; Future hideInputProductNameBanner() async { await _sharedPreferences.setBool( _TAG_SHOW_BANNER_INPUT_PRODUCT_NAME, false, ); notifyListeners(); } ProductType get latestProductType => ProductType.fromOffTag( _sharedPreferences.getString(_TAG_LATEST_PRODUCT_TYPE), ) ?? ProductType.food; set latestProductType(final ProductType value) => unawaited( _sharedPreferences.setString(_TAG_LATEST_PRODUCT_TYPE, value.offTag), ); Future setSearchProductTypeFilter(final bool visible) async { await _sharedPreferences.setBool( _TAG_SEARCH_SHOW_PRODUCT_TYPE_FILTER, visible, ); notifyListeners(); } bool get searchProductTypeFilterVisible => _sharedPreferences.getBool(_TAG_SEARCH_SHOW_PRODUCT_TYPE_FILTER) ?? false; List get productPageActions { final List? actions = _sharedPreferences.getStringList( _TAG_PRODUCT_PAGE_ACTIONS, ); if (actions == null) { return ProductFooterActionBar.defaultOrder(); } return actions .map((String action) => ProductFooterActionBar.fromKey(action)) .toList(growable: false); } Future setProductPageActions( final Iterable value, ) async { assert(!value.contains(ProductFooterActionBar.settings)); await _sharedPreferences.setStringList( _TAG_PRODUCT_PAGE_ACTIONS, value .map((ProductFooterActionBar action) => action.key) .toList(growable: false), ); notifyListeners(); } void increaseLanguageUsage(final OpenFoodFactsLanguage language) { final String? usage = _sharedPreferences.getString(_TAG_LANGUAGES_USAGE); final Map languages; if (usage == null || usage.isEmpty) { languages = {}; } else { languages = Map.from(jsonDecode(usage)); } languages[language.code] = (languages[language.code] ?? 0) + 1; unawaited( _sharedPreferences.setString(_TAG_LANGUAGES_USAGE, jsonEncode(languages)), ); } Map get languagesUsage { final String? usage = _sharedPreferences.getString(_TAG_LANGUAGES_USAGE); if (usage == null || usage.isEmpty) { return {}; } return Map.from(jsonDecode(usage)); } List get productPageTabs => _sharedPreferences.getStringList(_TAG_PRODUCT_PAGE_TABS) ?? []; Future setProductPageTabs(final List value) async { await _sharedPreferences.setStringList(_TAG_PRODUCT_PAGE_TABS, value); notifyListeners(); } }