diff --git a/packages/smooth_app/assets/app/logo_text_black.svg b/packages/smooth_app/assets/app/logo_text_black.svg new file mode 100644 index 0000000000..00d802eefe --- /dev/null +++ b/packages/smooth_app/assets/app/logo_text_black.svg @@ -0,0 +1,45 @@ + + + Logo OFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/smooth_app/assets/app/logo_text_white.svg b/packages/smooth_app/assets/app/logo_text_white.svg new file mode 100644 index 0000000000..098faa7b51 --- /dev/null +++ b/packages/smooth_app/assets/app/logo_text_white.svg @@ -0,0 +1,45 @@ + + + Logo OFF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/smooth_app/lib/data_models/preferences/user_preferences.dart b/packages/smooth_app/lib/data_models/preferences/user_preferences.dart index 70e5c9265f..a9496dfda1 100644 --- a/packages/smooth_app/lib/data_models/preferences/user_preferences.dart +++ b/packages/smooth_app/lib/data_models/preferences/user_preferences.dart @@ -108,6 +108,11 @@ class UserPreferences extends ChangeNotifier { 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'; + Future init(final ProductPreferences productPreferences) async { await _onMigrate(); @@ -372,4 +377,56 @@ class UserPreferences extends ChangeNotifier { _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, + ); + } + } } diff --git a/packages/smooth_app/lib/data_models/tagline.dart b/packages/smooth_app/lib/data_models/tagline.dart deleted file mode 100644 index 9c392294b1..0000000000 --- a/packages/smooth_app/lib/data_models/tagline.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:openfoodfacts/openfoodfacts.dart'; -import 'package:smooth_app/helpers/collections_helper.dart'; -import 'package:smooth_app/query/product_query.dart'; - -/// A tagline is the text displayed on the homepage -/// It may contain a link to an external resource -/// No cache is expected here -/// API URL: [https://world.openfoodfacts.org/files/tagline-off-ios-v2.json] or -/// [https://world.openfoodfacts.org/files/tagline-off-android-v2.json] -Future fetchTagLine() { - final String locale = ProductQuery.getLanguage().code; - - return http - .get( - Uri.https( - 'world.openfoodfacts.org', - _tagLineUrl, - ), - ) - .then( - (http.Response value) => const Utf8Decoder().convert(value.bodyBytes)) - .then((String value) => - _TagLine.fromJSON(jsonDecode(value) as List)) - .then((_TagLine tagLine) => tagLine[locale] ?? tagLine['en']) - .catchError((dynamic err) => null); -} - -/// Based on the platform, the URL may differ -String get _tagLineUrl { - if (Platform.isIOS || Platform.isMacOS) { - return '/files/tagline-off-ios-v2.json'; - } else { - return '/files/tagline-off-android-v2.json'; - } -} - -class _TagLine { - _TagLine.fromJSON(List json) - : _items = Map.fromEntries( - json.map( - (dynamic element) { - return MapEntry( - ((element as Map)['language'] as String) - .toLowerCase(), - TagLineItem._fromJSON(element['data'] as Map), - ); - }, - ), - ); - - /// Taglines by their locale - final Map _items; - - /// Finds a tagline with its locale - TagLineItem? operator [](String key) { - final String locale = key.toLowerCase(); - - // Let's try with the full locale - if (_items.containsKey(locale)) { - return _items[locale]; - } - - // Let's try with the language only (eg => fr_FR to fr) - final String languageCode = locale.substring(0, 2); - - if (_items.containsKey(languageCode)) { - return _items[languageCode]; - } else { - // Finally let's try with a subset (eg => no fr_BE but fr_FR) - return _items.getValueByKeyStartWith(languageCode, ignoreCase: true); - } - } -} - -class TagLineItem { - TagLineItem._fromJSON(Map json) - : url = json['url'] as String, - message = json['message'] as String; - - final String url; - final String message; - - bool get hasLink => url.startsWith('http'); -} diff --git a/packages/smooth_app/lib/data_models/tagline/tagline_json.dart b/packages/smooth_app/lib/data_models/tagline/tagline_json.dart new file mode 100644 index 0000000000..4fced2ae96 --- /dev/null +++ b/packages/smooth_app/lib/data_models/tagline/tagline_json.dart @@ -0,0 +1,415 @@ +part of 'tagline_provider.dart'; + +/// Content from the JSON and converted to what's in "tagmodel.dart" + +class _TagLineJSON { + _TagLineJSON.fromJson(Map json) + : news = (json['news'] as Map).map( + (dynamic id, dynamic value) => MapEntry( + id, + _TagLineItemNewsItem.fromJson(id, value), + ), + ), + taglineFeed = _TaglineJSONFeed.fromJson(json['tagline_feed']); + + final _TagLineJSONNewsList news; + final _TaglineJSONFeed taglineFeed; + + TagLine toTagLine(String locale) { + final Map tagLineNews = news.map( + (String key, _TagLineItemNewsItem value) => + MapEntry( + key, + value.toTagLineItem(locale), + ), + ); + + final _TagLineJSONFeedLocale localizedFeed = taglineFeed.loadNews(locale); + final Iterable feed = localizedFeed.news + .map((_TagLineJSONFeedLocaleItem item) { + if (news[item.id] == null) { + // The asked ID doesn't exist in the news + return null; + } + return item.overrideNewsItem(news[item.id]!, locale); + }) + .where((TagLineFeedItem? item) => + item != null && + (item.startDate == null || + item.startDate!.isBefore(DateTime.now())) && + (item.endDate == null || item.endDate!.isAfter(DateTime.now()))) + .whereNotNull(); + + return TagLine( + news: TagLineNewsList(tagLineNews), + feed: TagLineFeed( + feed.toList(growable: false), + ), + ); + } +} + +typedef _TagLineJSONNewsList = Map; + +class _TagLineItemNewsItem { + const _TagLineItemNewsItem._({ + required this.id, + required this.url, + required _TagLineItemNewsTranslations translations, + this.startDate, + this.endDate, + this.style, + }) : _translations = translations; + + _TagLineItemNewsItem.fromJson(this.id, Map json) + : assert((json['url'] as String).isNotEmpty), + url = json['url'], + assert((json['translations'] as Map) + .containsKey('default')), + _translations = (json['translations'] as Map) + .map((dynamic key, dynamic value) { + if (key == 'default') { + return MapEntry( + key, _TagLineItemNewsTranslationDefault.fromJson(value)); + } else { + return MapEntry( + key, + _TagLineItemNewsTranslation.fromJson(value), + ); + } + }), + startDate = DateTime.tryParse(json['start_date']), + endDate = DateTime.tryParse(json['end_date']), + style = json['style'] == null + ? null + : _TagLineNewsStyle.fromJson(json['style']); + + final String id; + final String url; + final _TagLineItemNewsTranslations _translations; + final DateTime? startDate; + final DateTime? endDate; + final _TagLineNewsStyle? style; + + _TagLineItemNewsTranslation loadTranslation(String locale) { + _TagLineItemNewsTranslation? translation; + // Direct match + if (_translations.containsKey(locale)) { + translation = _translations[locale]; + } else if (locale.contains('_')) { + final String languageCode = locale.split('_').first; + if (_translations.containsKey(languageCode)) { + translation = _translations[languageCode]; + } + } + + return _translations['default']!.merge(translation); + } + + TagLineNewsItem toTagLineItem(String locale) { + final _TagLineItemNewsTranslation translation = loadTranslation(locale); + // We can assume the default translation has a non-null title and message + return TagLineNewsItem( + id: id, + title: translation.title!, + message: translation.message!, + url: translation.url ?? url, + buttonLabel: translation.buttonLabel, + startDate: startDate, + endDate: endDate, + style: style?.toTagLineStyle(), + image: translation.image?.toTagLineImage(), + ); + } + + _TagLineItemNewsItem copyWith({ + String? url, + _TagLineItemNewsTranslations? translations, + DateTime? startDate, + DateTime? endDate, + _TagLineNewsStyle? style, + }) { + return _TagLineItemNewsItem._( + id: id, + // Still the same + url: url ?? this.url, + translations: translations ?? _translations, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + style: style ?? this.style, + ); + } +} + +typedef _TagLineItemNewsTranslations = Map; + +class _TagLineItemNewsTranslation { + _TagLineItemNewsTranslation._({ + this.title, + this.message, + this.url, + this.buttonLabel, + this.image, + }); + + _TagLineItemNewsTranslation.fromJson(Map json) + : assert(json['title'] == null || (json['title'] as String).isNotEmpty), + assert( + json['message'] == null || (json['message'] as String).isNotEmpty), + assert(json['url'] == null || (json['url'] as String).isNotEmpty), + assert(json['button_label'] == null || + (json['button_label'] as String).isNotEmpty), + title = json['title'], + message = json['message'], + url = json['url'], + buttonLabel = json['button_label'], + image = json['image'] == null + ? null + : _TagLineNewsImage.fromJson(json['image']); + final String? title; + final String? message; + final String? url; + final String? buttonLabel; + final _TagLineNewsImage? image; + + _TagLineItemNewsTranslation copyWith({ + String? title, + String? message, + String? url, + String? buttonLabel, + _TagLineNewsImage? image, + }) { + return _TagLineItemNewsTranslation._( + title: title ?? this.title, + message: message ?? this.message, + url: url ?? this.url, + buttonLabel: buttonLabel ?? this.buttonLabel, + image: image ?? this.image, + ); + } + + _TagLineItemNewsTranslation merge(_TagLineItemNewsTranslation? other) { + if (other == null) { + return this; + } + + return copyWith( + title: other.title, + message: other.message, + url: other.url, + buttonLabel: other.buttonLabel, + image: other.image, + ); + } +} + +class _TagLineItemNewsTranslationDefault extends _TagLineItemNewsTranslation { + _TagLineItemNewsTranslationDefault.fromJson(Map json) + : assert((json['title'] as String).isNotEmpty), + assert((json['message'] as String).isNotEmpty), + super.fromJson(json); +} + +class _TagLineNewsImage { + _TagLineNewsImage.fromJson(Map json) + : assert((json['url'] as String).isNotEmpty), + assert(json['width'] == null || + ((json['width'] as num) >= 0.0 && (json['width'] as num) <= 1.0)), + assert(json['alt'] == null || (json['alt'] as String).isNotEmpty), + url = json['url'], + width = json['width'], + alt = json['alt']; + + final String url; + final double? width; + final String? alt; + + TagLineImage toTagLineImage() { + return TagLineImage( + src: url, + width: width, + alt: alt, + ); + } +} + +class _TagLineNewsStyle { + _TagLineNewsStyle._({ + this.titleBackground, + this.titleTextColor, + this.titleIndicatorColor, + this.messageBackground, + this.messageTextColor, + this.buttonBackground, + this.buttonTextColor, + this.contentBackgroundColor, + }); + + _TagLineNewsStyle.fromJson(Map json) + : assert(json['title_background'] == null || + (json['title_background'] as String).startsWith('#')), + assert(json['title_text_color'] == null || + (json['title_text_color'] as String).startsWith('#')), + assert(json['title_indicator_color'] == null || + (json['title_indicator_color'] as String).startsWith('#')), + assert(json['message_background'] == null || + (json['message_background'] as String).startsWith('#')), + assert(json['message_text_color'] == null || + (json['message_text_color'] as String).startsWith('#')), + assert(json['button_background'] == null || + (json['button_background'] as String).startsWith('#')), + assert(json['button_text_color'] == null || + (json['button_text_color'] as String).startsWith('#')), + assert(json['content_background_color'] == null || + (json['content_background_color'] as String).startsWith('#')), + titleBackground = json['title_background'], + titleTextColor = json['title_text_color'], + titleIndicatorColor = json['title_indicator_color'], + messageBackground = json['message_background'], + messageTextColor = json['message_text_color'], + buttonBackground = json['button_background'], + buttonTextColor = json['button_text_color'], + contentBackgroundColor = json['content_background_color']; + + final String? titleBackground; + final String? titleTextColor; + final String? titleIndicatorColor; + final String? messageBackground; + final String? messageTextColor; + final String? buttonBackground; + final String? buttonTextColor; + final String? contentBackgroundColor; + + _TagLineNewsStyle copyWith({ + String? titleBackground, + String? titleTextColor, + String? titleIndicatorColor, + String? messageBackground, + String? messageTextColor, + String? buttonBackground, + String? buttonTextColor, + String? contentBackgroundColor, + }) { + return _TagLineNewsStyle._( + titleBackground: titleBackground ?? this.titleBackground, + titleTextColor: titleTextColor ?? this.titleTextColor, + titleIndicatorColor: titleIndicatorColor ?? this.titleIndicatorColor, + messageBackground: messageBackground ?? this.messageBackground, + messageTextColor: messageTextColor ?? this.messageTextColor, + buttonBackground: buttonBackground ?? this.buttonBackground, + buttonTextColor: buttonTextColor ?? this.buttonTextColor, + contentBackgroundColor: + contentBackgroundColor ?? this.contentBackgroundColor, + ); + } + + TagLineStyle toTagLineStyle() => TagLineStyle.fromHexa( + titleBackground: titleBackground, + titleTextColor: titleTextColor, + titleIndicatorColor: titleIndicatorColor, + messageBackground: messageBackground, + messageTextColor: messageTextColor, + buttonBackground: buttonBackground, + buttonTextColor: buttonTextColor, + contentBackgroundColor: contentBackgroundColor, + ); +} + +class _TaglineJSONFeed { + _TaglineJSONFeed.fromJson(Map json) + : assert(json.containsKey('default')), + _news = json.map( + (dynamic key, dynamic value) => + MapEntry( + key, + _TagLineJSONFeedLocale.fromJson(value), + ), + ); + + final _TagLineJSONFeedList _news; + + _TagLineJSONFeedLocale loadNews(String locale) { + // Direct match + if (_news.containsKey(locale)) { + return _news[locale]!; + } + + // Try by language + if (locale.contains('_')) { + final String languageCode = locale.split('_').first; + if (_news.containsKey(languageCode)) { + return _news[languageCode]!; + } + } + + return _news['default']!; + } +} + +typedef _TagLineJSONFeedList = Map; + +class _TagLineJSONFeedLocale { + _TagLineJSONFeedLocale.fromJson(Map json) + : assert(json['news'] is Iterable), + news = (json['news'] as Iterable) + .map((dynamic json) => _TagLineJSONFeedLocaleItem.fromJson(json)); + + final Iterable<_TagLineJSONFeedLocaleItem> news; +} + +class _TagLineJSONFeedLocaleItem { + _TagLineJSONFeedLocaleItem.fromJson(Map json) + : assert((json['id'] as String).isNotEmpty), + id = json['id'], + overrideContent = json['override'] != null + ? _TagLineJSONFeedNewsItemOverride.fromJson( + json['override'] as Map) + : null; + + final String id; + final _TagLineJSONFeedNewsItemOverride? overrideContent; + + TagLineFeedItem overrideNewsItem( + _TagLineItemNewsItem newsItem, + String locale, + ) { + _TagLineItemNewsItem item = newsItem; + + if (overrideContent != null) { + item = newsItem.copyWith( + url: overrideContent!.url ?? newsItem.url, + startDate: overrideContent!.startDate ?? newsItem.startDate, + endDate: overrideContent!.endDate ?? newsItem.endDate, + style: overrideContent!.style ?? newsItem.style, + ); + } + + final TagLineNewsItem tagLineItem = item.toTagLineItem(locale); + + return TagLineFeedItem( + news: tagLineItem, + startDate: tagLineItem.startDate, + endDate: tagLineItem.endDate, + ); + } +} + +class _TagLineJSONFeedNewsItemOverride { + _TagLineJSONFeedNewsItemOverride.fromJson(Map json) + : assert(json['url'] == null || (json['url'] as String).isNotEmpty), + url = json['url'], + startDate = json['start_date'] != null + ? DateTime.tryParse(json['start_date']) + : null, + endDate = json['end_date'] != null + ? DateTime.tryParse(json['end_date']) + : null, + style = json['style'] == null + ? null + : _TagLineNewsStyle.fromJson(json['style']); + + final String? url; + final DateTime? startDate; + final DateTime? endDate; + final _TagLineNewsStyle? style; +} diff --git a/packages/smooth_app/lib/data_models/tagline/tagline_model.dart b/packages/smooth_app/lib/data_models/tagline/tagline_model.dart new file mode 100644 index 0000000000..20698d5713 --- /dev/null +++ b/packages/smooth_app/lib/data_models/tagline/tagline_model.dart @@ -0,0 +1,164 @@ +import 'dart:ui'; + +class TagLine { + const TagLine({ + required this.news, + required this.feed, + }); + + final TagLineNewsList news; + final TagLineFeed feed; + + @override + String toString() { + return 'TagLine{news: $news, feed: $feed}'; + } +} + +class TagLineNewsList { + const TagLineNewsList(Map news) : _news = news; + + final Map _news; + + TagLineNewsItem? operator [](String key) => _news[key]; + + @override + String toString() { + return 'TagLineNewsList{_news: $_news}'; + } +} + +class TagLineNewsItem { + const TagLineNewsItem({ + required this.id, + required this.title, + required this.message, + required this.url, + this.buttonLabel, + this.startDate, + this.endDate, + this.image, + this.style, + }); + + final String id; + final String title; + final String message; + final String url; + final String? buttonLabel; + final DateTime? startDate; + final DateTime? endDate; + final TagLineImage? image; + final TagLineStyle? style; + + @override + String toString() { + return 'TagLineNewsItem{id: $id, title: $title, message: $message, url: $url, buttonLabel: $buttonLabel, startDate: $startDate, endDate: $endDate, image: $image, style: $style}'; + } +} + +class TagLineStyle { + const TagLineStyle({ + this.titleBackground, + this.titleTextColor, + this.titleIndicatorColor, + this.messageBackground, + this.messageTextColor, + this.buttonBackground, + this.buttonTextColor, + this.contentBackgroundColor, + }); + + TagLineStyle.fromHexa({ + String? titleBackground, + String? titleTextColor, + String? titleIndicatorColor, + String? messageBackground, + String? messageTextColor, + String? buttonBackground, + String? buttonTextColor, + String? contentBackgroundColor, + }) : titleBackground = _parseColor(titleBackground), + titleTextColor = _parseColor(titleTextColor), + titleIndicatorColor = _parseColor(titleIndicatorColor), + messageBackground = _parseColor(messageBackground), + messageTextColor = _parseColor(messageTextColor), + buttonBackground = _parseColor(buttonBackground), + buttonTextColor = _parseColor(buttonTextColor), + contentBackgroundColor = _parseColor(contentBackgroundColor); + + final Color? titleBackground; + final Color? titleTextColor; + final Color? titleIndicatorColor; + final Color? messageBackground; + final Color? messageTextColor; + final Color? buttonBackground; + final Color? buttonTextColor; + final Color? contentBackgroundColor; + + static Color? _parseColor(String? hexa) { + if (hexa == null || hexa.length != 7) { + return null; + } + return Color(int.parse(hexa.substring(1), radix: 16)); + } + + @override + String toString() { + return 'TagLineStyle{titleBackground: $titleBackground, titleTextColor: $titleTextColor, titleIndicatorColor: $titleIndicatorColor, messageBackground: $messageBackground, messageTextColor: $messageTextColor, buttonBackground: $buttonBackground, buttonTextColor: $buttonTextColor, contentBackgroundColor: $contentBackgroundColor}'; + } +} + +class TagLineImage { + const TagLineImage({ + required this.src, + this.width, + this.alt, + }); + + final String src; + final double? width; + final String? alt; + + @override + String toString() { + return 'TagLineImage{src: $src, width: $width, alt: $alt}'; + } +} + +class TagLineFeed { + const TagLineFeed(this.news); + + final List news; + + bool get isNotEmpty => news.isNotEmpty; + + @override + String toString() { + return 'TagLineFeed{news: $news}'; + } +} + +class TagLineFeedItem { + const TagLineFeedItem({ + required this.news, + DateTime? startDate, + DateTime? endDate, + }) : _startDate = startDate, + _endDate = endDate; + + final TagLineNewsItem news; + final DateTime? _startDate; + final DateTime? _endDate; + + String get id => news.id; + + DateTime? get startDate => _startDate ?? news.startDate; + + DateTime? get endDate => _endDate ?? news.endDate; + + @override + String toString() { + return 'TagLineFeedItem{news: $news, _startDate: $_startDate, _endDate: $_endDate}'; + } +} diff --git a/packages/smooth_app/lib/data_models/tagline/tagline_provider.dart b/packages/smooth_app/lib/data_models/tagline/tagline_provider.dart new file mode 100644 index 0000000000..8c96122f8d --- /dev/null +++ b/packages/smooth_app/lib/data_models/tagline/tagline_provider.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:smooth_app/data_models/tagline/tagline_model.dart'; +import 'package:smooth_app/query/product_query.dart'; + +part 'tagline_json.dart'; + +/// The TagLine provides one one side a list of news and on the other a feed +/// containing the some of the news +/// +/// The TagLine is fetched on the server and cached locally (1 day). +/// To be notified of changes, listen to this [ChangeNotifier] and more +/// particularly to the [state] property +class TagLineProvider extends ChangeNotifier { + TagLineProvider() : _state = const TagLineLoading() { + loadTagLine(); + } + + TagLineState _state; + + bool get hasContent => _state is TagLineLoaded; + + Future loadTagLine({bool forceUpdate = false}) async { + _emit(const TagLineLoading()); + + final String locale = ProductQuery.getLocaleString(); + if (locale.startsWith('-')) { + // ProductQuery not ready + return; + } + + final File cacheFile = await _tagLineCacheFile; + String? jsonString; + // Try from the cache first + if (!forceUpdate && _isTagLineCacheValid(cacheFile)) { + jsonString = cacheFile.readAsStringSync(); + } + + if (jsonString == null || jsonString.isEmpty == true) { + jsonString = await _fetchTagLine(); + } + + if (jsonString?.isNotEmpty != true) { + _emit(const TagLineError('JSON file is empty')); + return; + } + + final TagLine? tagLine = await Isolate.run( + () => _parseJSONAndGetLocalizedContent(jsonString!, locale)); + if (tagLine == null) { + _emit(const TagLineError('Unable to parse the JSON file')); + } else { + _emit(TagLineLoaded(tagLine)); + } + } + + void _emit(TagLineState state) { + _state = state; + try { + notifyListeners(); + } catch (_) {} + } + + TagLineState get state => _state; + + static Future _parseJSONAndGetLocalizedContent( + String json, + String locale, + ) async { + try { + final _TagLineJSON tagLineJSON = + _TagLineJSON.fromJson(jsonDecode(json) as Map); + return tagLineJSON.toTagLine(locale); + } catch (_) { + return null; + } + } + + /// API URL: [https://world.openfoodfacts.org/files/tagline-off-ios-v3.json] + /// or [https://world.openfoodfacts.org/files/tagline-off-android-v3.json] + Future _fetchTagLine() async { + try { + final http.Response response = + await http.get(Uri.https('world.openfoodfacts.org', _tagLineUrl)); + + final String json = const Utf8Decoder().convert(response.bodyBytes); + + if (!json.startsWith('[') && !json.startsWith('{')) { + throw Exception('Invalid JSON'); + } + await _saveTagLineToCache(json); + return json; + } catch (_) { + return null; + } + } + + /// Based on the platform, the URL may differ + String get _tagLineUrl { + if (Platform.isIOS || Platform.isMacOS) { + return '/files/tagline-off-ios-v3.json'; + } else { + return '/files/tagline-off-android-v3.json'; + } + } + + Future get _tagLineCacheFile => getApplicationCacheDirectory() + .then((Directory dir) => File(join(dir.path, 'tagline.json'))); + + Future _saveTagLineToCache(final String json) async { + final File file = await _tagLineCacheFile; + return file.writeAsString(json); + } + + bool _isTagLineCacheValid(File file) => + file.existsSync() && + file.lengthSync() > 0 && + file + .lastModifiedSync() + .isAfter(DateTime.now().add(const Duration(days: -1))); +} + +sealed class TagLineState { + const TagLineState(); +} + +final class TagLineLoading extends TagLineState { + const TagLineLoading(); +} + +class TagLineLoaded extends TagLineState { + const TagLineLoaded(this.tagLineContent); + + final TagLine tagLineContent; +} + +class TagLineError extends TagLineState { + const TagLineError(this.exception); + + final dynamic exception; +} diff --git a/packages/smooth_app/lib/helpers/provider_helper.dart b/packages/smooth_app/lib/helpers/provider_helper.dart index 9af48309ec..3e5753cc5a 100644 --- a/packages/smooth_app/lib/helpers/provider_helper.dart +++ b/packages/smooth_app/lib/helpers/provider_helper.dart @@ -38,3 +38,59 @@ class _ListenerState extends SingleChildState> { return child ?? const SizedBox.shrink(); } } + +/// Same as [Consumer] but only rebuilds if [buildWhen] returns true +/// (And on the first build) +class ConsumerFilter extends StatefulWidget { + const ConsumerFilter({ + required this.builder, + required this.buildWhen, + this.child, + super.key, + }); + + final Widget Function( + BuildContext context, + T value, + Widget? child, + ) builder; + final bool Function(T? previousValue, T currentValue) buildWhen; + + final Widget? child; + + @override + State> createState() => _ConsumerFilterState(); +} + +class _ConsumerFilterState extends State> { + T? oldValue; + Widget? oldWidget; + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (BuildContext context, T value, Widget? child) { + if (widget.buildWhen(oldValue, value) || oldWidget == null) { + oldWidget = widget.builder( + context, + value, + child, + ); + } + + oldValue = value; + + return widget.builder( + context, + value, + oldWidget, + ); + }, + child: widget.child, + ); + } +} + +extension ValueNotifierExtensions on ValueNotifier { + void emit(T value) => this.value = value; +} diff --git a/packages/smooth_app/lib/helpers/strings_helper.dart b/packages/smooth_app/lib/helpers/strings_helper.dart index fa8f10c510..962a0ad19c 100644 --- a/packages/smooth_app/lib/helpers/strings_helper.dart +++ b/packages/smooth_app/lib/helpers/strings_helper.dart @@ -1,4 +1,4 @@ -import 'package:flutter/painting.dart'; +import 'package:flutter/material.dart'; extension StringExtensions on String { /// Returns a list containing all positions of a [charCode] @@ -93,3 +93,41 @@ class TextHelper { return parts; } } + +class FormattedText extends StatelessWidget { + const FormattedText({ + required this.text, + this.textStyle, + this.textAlign, + }); + + final String text; + final TextStyle? textStyle; + final TextAlign? textAlign; + + @override + Widget build(BuildContext context) { + final TextStyle defaultTextStyle = textStyle ?? const TextStyle(); + + return RichText( + text: TextSpan( + style: DefaultTextStyle.of(context).style, + children: TextHelper.getPartsBetweenSymbol( + text: text, + symbol: r'\*\*', + symbolLength: 2, + defaultStyle: defaultTextStyle, + highlightedStyle: const TextStyle(fontWeight: FontWeight.bold)) + .map( + ((String, TextStyle?) part) { + return TextSpan( + text: part.$1, + style: defaultTextStyle.merge(part.$2), + ); + }, + ).toList(growable: false), + ), + textAlign: textAlign ?? TextAlign.start, + ); + } +} diff --git a/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_title_card.dart b/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_title_card.dart index ae51b6fc00..743683927e 100644 --- a/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_title_card.dart +++ b/packages/smooth_app/lib/knowledge_panel/knowledge_panels/knowledge_panel_title_card.dart @@ -79,6 +79,7 @@ class KnowledgePanelTitleCard extends StatelessWidget { child: Semantics( value: _generateSemanticsValue(context), button: isClickable, + container: true, excludeSemantics: true, child: Row( children: [ diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index cd6add50ca..cc484540a5 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -742,8 +742,6 @@ "@no_product_found": {}, "no_location_found": "No location found", "not_found": "not found:", - "searchPanelHeader": "Search or scan your first product", - "@Product query status": {}, "refreshing_product": "Refreshing product", "@refreshing_product": { "description": "Confirmation, that the product data of a cached product is queried again" @@ -752,10 +750,24 @@ "@product_refreshed": { "description": "Confirmation, that the product data refresh is done" }, + "homepage_main_card_logo_description": "Welcome to Open Food Facts", + "@homepage_main_card_logo_description": { + "description": "Description for accessibility of the Open Food Facts logo on the homepage" + }, + "homepage_main_card_subheading": "**Scan** a barcode or\n**search** for a product", + "@homepage_main_card_subheading": { + "description": "Text between asterisks (eg: **My Text**) means text in bold. Please keep it." + }, + "homepage_main_card_search_field_hint": "Search for a product", + "homepage_main_card_search_field_tooltip": "Start search", + "@homepage_main_card_search_field_tooltip": { + "description": "Description for accessibility of the search field on the homepage" + }, "tagline_app_review": "Do you like the app?", "tagline_app_review_button_positive": "I love it! 😍", "tagline_app_review_button_negative": "Not really…", "tagline_app_review_button_later": "Ask me later", + "tagline_feed_news_button": "Know more", "app_review_negative_modal_title": "You don't like our app?", "app_review_negative_modal_text": "Could you take a few seconds to tell us why?", "app_review_negative_modal_positive_button": "Yes, absolutely!", diff --git a/packages/smooth_app/lib/main.dart b/packages/smooth_app/lib/main.dart index 70ba4abdeb..76f7ab4157 100644 --- a/packages/smooth_app/lib/main.dart +++ b/packages/smooth_app/lib/main.dart @@ -18,6 +18,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/data_models/preferences/user_preferences.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; +import 'package:smooth_app/data_models/tagline/tagline_provider.dart'; import 'package:smooth_app/data_models/user_management_provider.dart'; import 'package:smooth_app/database/dao_string.dart'; import 'package:smooth_app/database/local_database.dart'; @@ -105,6 +106,7 @@ late TextContrastProvider _textContrastProvider; final ContinuousScanModel _continuousScanModel = ContinuousScanModel(); final PermissionListener _permissionListener = PermissionListener(permission: Permission.camera); +final TagLineProvider _tagLineProvider = TagLineProvider(); bool _init1done = false; // Had to split init in 2 methods, for test/screenshots reasons. @@ -199,8 +201,12 @@ class _SmoothAppState extends State { // The `create` constructor of [ChangeNotifierProvider] takes care of // disposing the value. - ChangeNotifierProvider provide(T value) => - ChangeNotifierProvider(create: (BuildContext context) => value); + ChangeNotifierProvider provide(T value, + {bool? lazy}) => + ChangeNotifierProvider( + create: (BuildContext context) => value, + lazy: lazy, + ); if (!_screenshots) { // ending FlutterNativeSplash.preserve() @@ -218,6 +224,7 @@ class _SmoothAppState extends State { provide(_userManagementProvider), provide(_continuousScanModel), provide(_permissionListener), + provide(_tagLineProvider, lazy: true), ], child: AnimationsLoader( child: AppNavigator( diff --git a/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart b/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart index c5d3c55df3..983f588dc2 100644 --- a/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart +++ b/packages/smooth_app/lib/pages/guides/helpers/guides_content.dart @@ -264,27 +264,7 @@ class _GuidesFormattedText extends StatelessWidget { @override Widget build(BuildContext context) { - const TextStyle defaultTextStyle = TextStyle(); - - return RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: TextHelper.getPartsBetweenSymbol( - text: text, - symbol: r'\*\*', - symbolLength: 2, - defaultStyle: defaultTextStyle, - highlightedStyle: const TextStyle(fontWeight: FontWeight.bold)) - .map( - ((String, TextStyle?) part) { - return TextSpan( - text: part.$1, - style: defaultTextStyle.merge(part.$2), - ); - }, - ).toList(growable: false), - ), - ); + return FormattedText(text: text); } } diff --git a/packages/smooth_app/lib/pages/navigator/app_navigator.dart b/packages/smooth_app/lib/pages/navigator/app_navigator.dart index 695c23745f..02c951306d 100644 --- a/packages/smooth_app/lib/pages/navigator/app_navigator.dart +++ b/packages/smooth_app/lib/pages/navigator/app_navigator.dart @@ -5,6 +5,7 @@ import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/preferences/user_preferences.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; +import 'package:smooth_app/data_models/tagline/tagline_provider.dart'; import 'package:smooth_app/helpers/analytics_helper.dart'; import 'package:smooth_app/helpers/extension_on_text_helper.dart'; import 'package:smooth_app/pages/carousel_manager.dart'; @@ -312,6 +313,7 @@ class _SmoothGoRouter { // Must be set first to ensure the method is only called once _appLanguageInitialized = true; ProductQuery.setLanguage(context, context.read()); + context.read().loadTagLine(); return context.read().refresh(); } diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart index 5509417f1a..756706ee82 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_language_selector.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:smooth_app/background/background_task_language_refresh.dart'; import 'package:smooth_app/data_models/preferences/user_preferences.dart'; import 'package:smooth_app/data_models/product_preferences.dart'; +import 'package:smooth_app/data_models/tagline/tagline_provider.dart'; import 'package:smooth_app/database/local_database.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/widgets/language_selector.dart'; @@ -55,6 +56,11 @@ class UserPreferencesLanguageSelector extends StatelessWidget { await BackgroundTaskLanguageRefresh.addTask( context.read(), ); + + // Refresh the tagline + if (context.mounted) { + context.read().loadTagLine(); + } // TODO(monsieurtanuki): make it a background task also? // no await productPreferences.refresh(); diff --git a/packages/smooth_app/lib/pages/scan/scan_page.dart b/packages/smooth_app/lib/pages/scan/scan_page.dart index 500972deb9..d352c36efc 100644 --- a/packages/smooth_app/lib/pages/scan/scan_page.dart +++ b/packages/smooth_app/lib/pages/scan/scan_page.dart @@ -8,6 +8,7 @@ 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/data_models/preferences/user_preferences.dart'; +import 'package:smooth_app/data_models/tagline/tagline_provider.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; @@ -60,84 +61,88 @@ class _ScanPageState extends State { Theme.of(context).brightness == Brightness.light && Platform.isIOS ? Brightness.dark : null, - body: Container( - color: Colors.white, - child: SafeArea( - child: Container( - color: Theme.of(context).colorScheme.background, - child: Column( - children: [ - if (hasACamera) + body: ChangeNotifierProvider( + lazy: true, + create: (_) => TagLineProvider(), + child: Container( + color: Colors.white, + child: SafeArea( + child: Container( + color: Theme.of(context).colorScheme.background, + child: Column( + children: [ + if (hasACamera) + Expanded( + flex: 100 - _carouselHeightPct, + child: Consumer( + builder: ( + BuildContext context, + PermissionListener listener, + _, + ) { + switch (listener.value.status) { + case DevicePermissionStatus.checking: + return EMPTY_WIDGET; + case DevicePermissionStatus.granted: + // TODO(m123): change + return const CameraScannerPage(); + default: + return const _PermissionDeniedCard(); + } + }, + ), + ), Expanded( - flex: 100 - _carouselHeightPct, - child: Consumer( - builder: ( - BuildContext context, - PermissionListener listener, - _, - ) { - switch (listener.value.status) { - case DevicePermissionStatus.checking: - return EMPTY_WIDGET; - case DevicePermissionStatus.granted: - // TODO(m123): change - return const CameraScannerPage(); - default: - return const _PermissionDeniedCard(); - } - }, - ), - ), - Expanded( - flex: _carouselHeightPct, - child: Padding( - padding: const EdgeInsetsDirectional.only(bottom: 10.0), - child: SmoothProductCarousel( - containSearchCard: true, - onPageChangedTo: (int page, String? barcode) async { - if (barcode == null) { - // We only notify for new products - return; - } + flex: _carouselHeightPct, + child: Padding( + padding: const EdgeInsetsDirectional.only(bottom: 10.0), + child: SmoothProductCarousel( + containSearchCard: true, + onPageChangedTo: (int page, String? barcode) async { + if (barcode == null) { + // We only notify for new products + return; + } - // Both are Future methods, but it doesn't matter to wait here - SmoothHapticFeedback.lightNotification(); + // Both are Future methods, but it doesn't matter to wait here + SmoothHapticFeedback.lightNotification(); - if (_userPreferences.playCameraSound) { - await _initSoundManagerIfNecessary(); - await _musicPlayer!.stop(); - await _musicPlayer!.play( - AssetSource('audio/beep.wav'), - volume: 0.5, - ctx: const AudioContext( - android: AudioContextAndroid( - isSpeakerphoneOn: false, - stayAwake: false, - contentType: AndroidContentType.sonification, - usageType: AndroidUsageType.notification, - audioFocus: - AndroidAudioFocus.gainTransientMayDuck, + if (_userPreferences.playCameraSound) { + await _initSoundManagerIfNecessary(); + await _musicPlayer!.stop(); + await _musicPlayer!.play( + AssetSource('audio/beep.wav'), + volume: 0.5, + ctx: const AudioContext( + android: AudioContextAndroid( + isSpeakerphoneOn: false, + stayAwake: false, + contentType: AndroidContentType.sonification, + usageType: AndroidUsageType.notification, + audioFocus: + AndroidAudioFocus.gainTransientMayDuck, + ), + iOS: AudioContextIOS( + category: AVAudioSessionCategory.soloAmbient, + options: [ + AVAudioSessionOptions.mixWithOthers, + ], + ), ), - iOS: AudioContextIOS( - category: AVAudioSessionCategory.soloAmbient, - options: [ - AVAudioSessionOptions.mixWithOthers, - ], - ), - ), + ); + } + + SemanticsService.announce( + appLocalizations.scan_announce_new_barcode(barcode), + direction, + assertiveness: Assertiveness.assertive, ); - } - - SemanticsService.announce( - appLocalizations.scan_announce_new_barcode(barcode), - direction, - assertiveness: Assertiveness.assertive, - ); - }, + }, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/packages/smooth_app/lib/pages/scan/scan_tagline.dart b/packages/smooth_app/lib/pages/scan/scan_tagline.dart new file mode 100644 index 0000000000..1a906e818b --- /dev/null +++ b/packages/smooth_app/lib/pages/scan/scan_tagline.dart @@ -0,0 +1,435 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:smooth_app/cards/category_cards/svg_cache.dart'; +import 'package:smooth_app/data_models/preferences/user_preferences.dart'; +import 'package:smooth_app/data_models/tagline/tagline_model.dart'; +import 'package:smooth_app/data_models/tagline/tagline_provider.dart'; +import 'package:smooth_app/generic_lib/design_constants.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/helpers/launch_url_helper.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/helpers/strings_helper.dart'; +import 'package:smooth_app/resources/app_icons.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; + +class ScanTagLine extends StatelessWidget { + const ScanTagLine({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider<_ScanTagLineProvider>( + create: (BuildContext context) => _ScanTagLineProvider(context), + child: Consumer<_ScanTagLineProvider>( + builder: ( + BuildContext context, + _ScanTagLineProvider scanTagLineProvider, + Widget? child, + ) { + final _ScanTagLineState state = scanTagLineProvider.value; + + return switch (state) { + _ScanTagLineStateLoading() => const _ScanTagLineLoading(), + _ScanTagLineStateNoContent() => EMPTY_WIDGET, + _ScanTagLineStateLoaded() => _ScanTagLineContent( + news: state.tagLine, + ), + }; + }, + ), + ); + } +} + +class _ScanTagLineLoading extends StatelessWidget { + const _ScanTagLineLoading(); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: Theme.of(context) + .extension()! + .primaryMedium, + highlightColor: Colors.white, + child: const SmoothCard( + child: SizedBox( + width: double.infinity, + height: 200.0, + ), + ), + ); + } +} + +class _ScanTagLineContent extends StatefulWidget { + const _ScanTagLineContent({ + required this.news, + }); + + final Iterable news; + + @override + State<_ScanTagLineContent> createState() => _ScanTagLineContentState(); +} + +class _ScanTagLineContentState extends State<_ScanTagLineContent> { + Timer? _timer; + int _index = -1; + + @override + void initState() { + super.initState(); + _rotateNews(); + } + + void _rotateNews() { + _timer?.cancel(); + + _index++; + if (_index >= widget.news.length) { + _index = 0; + } + + _timer = Timer(const Duration(minutes: 30), () => _rotateNews()); + } + + @override + Widget build(BuildContext context) { + final ThemeProvider themeProvider = context.watch(); + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; + final TagLineNewsItem currentNews = widget.news.elementAt(_index); + + // Default values seem weird + const Radius radius = Radius.circular(16.0); + + return Column( + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: currentNews.style?.titleBackground ?? + (themeProvider.isLightTheme + ? theme.primarySemiDark + : theme.primaryBlack), + borderRadius: const BorderRadiusDirectional.only( + topStart: radius, + topEnd: radius, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: VERY_SMALL_SPACE, + horizontal: MEDIUM_SPACE, + ), + child: _TagLineContentTitle( + title: currentNews.title, + backgroundColor: currentNews.style?.titleBackground, + indicatorColor: currentNews.style?.titleIndicatorColor, + titleColor: currentNews.style?.titleTextColor, + ), + ), + ), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: currentNews.style?.contentBackgroundColor ?? + (themeProvider.isLightTheme + ? theme.primaryMedium + : theme.primaryDark), + borderRadius: const BorderRadiusDirectional.only( + bottomStart: radius, + bottomEnd: radius, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: SMALL_SPACE, + horizontal: MEDIUM_SPACE, + ), + child: Column( + children: [ + Expanded( + child: _TagLineContentBody( + message: currentNews.message, + textColor: currentNews.style?.messageTextColor, + image: currentNews.image, + ), + ), + const SizedBox(height: SMALL_SPACE), + Align( + alignment: AlignmentDirectional.bottomEnd, + child: _TagLineContentButton( + link: currentNews.url, + label: currentNews.buttonLabel, + backgroundColor: currentNews.style?.buttonBackground, + foregroundColor: currentNews.style?.buttonTextColor, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +class _TagLineContentTitle extends StatelessWidget { + const _TagLineContentTitle({ + required this.title, + this.backgroundColor, + this.indicatorColor, + this.titleColor, + }); + + final String title; + final Color? backgroundColor; + final Color? indicatorColor; + final Color? titleColor; + + @override + Widget build(BuildContext context) { + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; + + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30.0), + child: Row( + children: [ + SizedBox.square( + dimension: 11.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: indicatorColor ?? theme.secondaryLight, + borderRadius: const BorderRadius.all(ROUNDED_RADIUS), + ), + ), + ), + const SizedBox(width: SMALL_SPACE), + Expanded( + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16.0, + color: titleColor ?? Colors.white, + ), + )) + ], + ), + ); + } +} + +class _TagLineContentBody extends StatelessWidget { + const _TagLineContentBody({ + required this.message, + this.textColor, + this.image, + }); + + final String message; + final Color? textColor; + final TagLineImage? image; + + @override + Widget build(BuildContext context) { + final ThemeProvider themeProvider = context.watch(); + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; + + final Widget text = FormattedText( + text: message, + textStyle: TextStyle( + color: textColor ?? + (themeProvider.isLightTheme + ? theme.primarySemiDark + : theme.primaryLight), + ), + ); + + if (image == null) { + return text; + } + + final int imageFlex = ((image!.width ?? 0.2) * 10).toInt(); + return Row( + children: [ + Expanded( + flex: imageFlex, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.06, + ), + child: AspectRatio( + aspectRatio: 1.0, + child: _image(), + ), + ), + ), + const SizedBox(width: MEDIUM_SPACE), + Expanded( + flex: 10 - imageFlex, + child: text, + ), + ], + ); + } + + Widget _image() { + if (image!.src.endsWith('svg')) { + return SvgCache( + image!.src, + semanticsLabel: image!.alt, + ); + } else { + return Image.network( + semanticLabel: image!.alt, + image!.src, + ); + } + } +} + +class _TagLineContentButton extends StatelessWidget { + const _TagLineContentButton({ + required this.link, + this.label, + this.backgroundColor, + this.foregroundColor, + }); + + final String link; + final String? label; + final Color? backgroundColor; + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + final AppLocalizations localizations = AppLocalizations.of(context); + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; + + return FilledButton( + style: FilledButton.styleFrom( + backgroundColor: backgroundColor ?? theme.primaryBlack, + foregroundColor: foregroundColor ?? Colors.white, + padding: const EdgeInsets.symmetric( + vertical: VERY_SMALL_SPACE, + horizontal: MEDIUM_SPACE, + ), + minimumSize: const Size(0, 20.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label ?? localizations.tagline_feed_news_button), + const SizedBox(width: MEDIUM_SPACE), + const Arrow.right( + size: 12.0, + ), + ], + ), + onPressed: () => LaunchUrlHelper.launchURL(link), + ); + } +} + +/// Listen to [TagLineProvider] feed and provide a list of [TagLineNewsItem] +/// randomly sorted by unread, then displayed and clicked news. +class _ScanTagLineProvider extends ValueNotifier<_ScanTagLineState> { + _ScanTagLineProvider(BuildContext context) + : _tagLineProvider = context.read(), + _userPreferences = context.read(), + super(const _ScanTagLineStateLoading()) { + _tagLineProvider.addListener(_onTagLineStateChanged); + // Refresh with the current state + _onTagLineStateChanged(); + } + + final TagLineProvider _tagLineProvider; + final UserPreferences _userPreferences; + + void _onTagLineStateChanged() { + switch (_tagLineProvider.state) { + case TagLineLoading(): + emit(const _ScanTagLineStateLoading()); + case TagLineError(): + emit(const _ScanTagLineStateNoContent()); + case TagLineLoaded(): + _onTagLineContentAvailable( + (_tagLineProvider.state as TagLineLoaded).tagLineContent); + } + } + + Future _onTagLineContentAvailable(TagLine tagLine) async { + if (!tagLine.feed.isNotEmpty) { + emit(const _ScanTagLineStateNoContent()); + return; + } + + final List unreadNews = []; + final List displayedNews = []; + final List clickedNews = []; + + final List taglineFeedAlreadyClickedNews = + _userPreferences.taglineFeedClickedNews; + final List taglineFeedAlreadyDisplayedNews = + _userPreferences.taglineFeedDisplayedNews; + + for (final TagLineFeedItem feedItem in tagLine.feed.news) { + if (taglineFeedAlreadyClickedNews.contains(feedItem.id)) { + clickedNews.add(feedItem.news); + } else if (taglineFeedAlreadyDisplayedNews.contains(feedItem.id)) { + displayedNews.add(feedItem.news); + } else { + unreadNews.add(feedItem.news); + } + } + + emit( + _ScanTagLineStateLoaded( + [ + ...unreadNews..shuffle(), + ...displayedNews..shuffle(), + ...clickedNews..shuffle(), + ], + ), + ); + } + + @override + void dispose() { + _tagLineProvider.removeListener(_onTagLineStateChanged); + super.dispose(); + } +} + +sealed class _ScanTagLineState { + const _ScanTagLineState(); +} + +class _ScanTagLineStateLoading extends _ScanTagLineState { + const _ScanTagLineStateLoading(); +} + +class _ScanTagLineStateNoContent extends _ScanTagLineState { + const _ScanTagLineStateNoContent(); +} + +class _ScanTagLineStateLoaded extends _ScanTagLineState { + const _ScanTagLineStateLoaded(this.tagLine); + + final Iterable tagLine; +} diff --git a/packages/smooth_app/lib/themes/smooth_theme_colors.dart b/packages/smooth_app/lib/themes/smooth_theme_colors.dart index 0d5700760d..3c17181056 100644 --- a/packages/smooth_app/lib/themes/smooth_theme_colors.dart +++ b/packages/smooth_app/lib/themes/smooth_theme_colors.dart @@ -14,8 +14,8 @@ class SmoothColorsThemeExtension required this.green, required this.orange, required this.red, - required this.grayDark, - required this.grayLight, + required this.greyDark, + required this.greyLight, }); SmoothColorsThemeExtension.defaultValues() @@ -30,8 +30,8 @@ class SmoothColorsThemeExtension green = const Color(0xFF219653), orange = const Color(0xFFFB8229), red = const Color(0xFFEB5757), - grayDark = const Color(0xFF666666), - grayLight = const Color(0xFF8F8F8F); + greyDark = const Color(0xFF666666), + greyLight = const Color(0xFF8F8F8F); final Color primaryBlack; final Color primaryDark; @@ -44,8 +44,8 @@ class SmoothColorsThemeExtension final Color green; final Color orange; final Color red; - final Color grayDark; - final Color grayLight; + final Color greyDark; + final Color greyLight; @override ThemeExtension copyWith({ @@ -60,8 +60,8 @@ class SmoothColorsThemeExtension Color? green, Color? orange, Color? red, - Color? grayDark, - Color? grayLight, + Color? greyDark, + Color? greyLight, }) { return SmoothColorsThemeExtension( primaryBlack: primaryBlack ?? this.primaryBlack, @@ -75,8 +75,8 @@ class SmoothColorsThemeExtension green: green ?? this.green, orange: orange ?? this.orange, red: red ?? this.red, - grayDark: grayDark ?? this.grayDark, - grayLight: grayLight ?? this.grayLight, + greyDark: greyDark ?? this.greyDark, + greyLight: greyLight ?? this.greyLight, ); } @@ -145,14 +145,14 @@ class SmoothColorsThemeExtension other.red, t, )!, - grayDark: Color.lerp( - grayDark, - other.grayDark, + greyDark: Color.lerp( + greyDark, + other.greyDark, t, )!, - grayLight: Color.lerp( - grayLight, - other.grayLight, + greyLight: Color.lerp( + greyLight, + other.greyLight, t, )!, ); diff --git a/packages/smooth_app/lib/widgets/smooth_product_carousel.dart b/packages/smooth_app/lib/widgets/smooth_product_carousel.dart index 6761f901e4..af7695ae3f 100644 --- a/packages/smooth_app/lib/widgets/smooth_product_carousel.dart +++ b/packages/smooth_app/lib/widgets/smooth_product_carousel.dart @@ -1,33 +1,26 @@ -import 'dart:math'; - -import 'package:auto_size_text/auto_size_text.dart'; import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; import 'package:scanner_shared/scanner_shared.dart' hide EMPTY_WIDGET; -import 'package:smooth_app/cards/product_cards/smooth_product_base_card.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_error.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_loading.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_not_found.dart'; import 'package:smooth_app/cards/product_cards/smooth_product_card_thanks.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; -import 'package:smooth_app/data_models/preferences/user_preferences.dart'; -import 'package:smooth_app/data_models/tagline.dart'; +import 'package:smooth_app/data_models/tagline/tagline_provider.dart'; import 'package:smooth_app/generic_lib/design_constants.dart'; -import 'package:smooth_app/generic_lib/dialogs/smooth_alert_dialog.dart'; -import 'package:smooth_app/helpers/app_helper.dart'; -import 'package:smooth_app/helpers/camera_helper.dart'; -import 'package:smooth_app/helpers/launch_url_helper.dart'; -import 'package:smooth_app/helpers/user_feedback_helper.dart'; +import 'package:smooth_app/generic_lib/widgets/smooth_card.dart'; +import 'package:smooth_app/helpers/provider_helper.dart'; +import 'package:smooth_app/helpers/strings_helper.dart'; import 'package:smooth_app/pages/carousel_manager.dart'; import 'package:smooth_app/pages/navigator/app_navigator.dart'; -import 'package:smooth_app/pages/preferences/user_preferences_widgets.dart'; import 'package:smooth_app/pages/scan/scan_product_card_loader.dart'; -import 'package:smooth_app/pages/scan/search_page.dart'; -import 'package:smooth_app/pages/scan/search_product_helper.dart'; -import 'package:smooth_app/services/smooth_services.dart'; +import 'package:smooth_app/pages/scan/scan_tagline.dart'; +import 'package:smooth_app/resources/app_icons.dart'; +import 'package:smooth_app/themes/smooth_theme_colors.dart'; +import 'package:smooth_app/themes/theme_provider.dart'; class SmoothProductCarousel extends StatefulWidget { const SmoothProductCarousel({ @@ -127,7 +120,7 @@ class _SmoothProductCarouselState extends State { horizontal: HORIZONTAL_SPACE_BETWEEN_CARDS, ), child: widget.containSearchCard && itemIndex == 0 - ? SearchCard(height: constraints.maxHeight) + ? const _MainCard() : _getWidget(itemIndex - _searchCardAdjustment), ), ); @@ -204,6 +197,10 @@ class _SmoothProductCarouselState extends State { double _computeViewPortFraction() { final double screenWidth = MediaQuery.sizeOf(context).width; + if (barcodes.isEmpty) { + return 0.95; + } + return (screenWidth - (SmoothBarcodeScannerVisor.CORNER_PADDING * 2) - (SmoothBarcodeScannerVisor.STROKE_WIDTH * 2) + @@ -212,335 +209,171 @@ class _SmoothProductCarouselState extends State { } } -class SearchCard extends StatelessWidget { - const SearchCard({required this.height}); +class _MainCard extends StatelessWidget { + const _MainCard(); - final double height; + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ConsumerFilter( + buildWhen: + (TagLineProvider? previousValue, TagLineProvider currentValue) { + return previousValue?.hasContent != currentValue.hasContent; + }, + builder: (BuildContext context, TagLineProvider tagLineManager, _) { + if (!tagLineManager.hasContent) { + return const _SearchCard( + expandedMode: true, + ); + } else { + return const Column( + children: [ + Expanded( + flex: 6, + child: _SearchCard( + expandedMode: false, + ), + ), + SizedBox(height: MEDIUM_SPACE), + Expanded( + flex: 4, + child: ScanTagLine(), + ), + ], + ); + } + }, + ), + ), + ], + ); + } +} - static const double OPACITY = 0.85; +class _SearchCard extends StatelessWidget { + const _SearchCard({ + required this.expandedMode, + }); + + /// Expanded is when this card is the only one (no tagline, no app review…) + final bool expandedMode; @override Widget build(BuildContext context) { final AppLocalizations localizations = AppLocalizations.of(context); + final ThemeProvider themeProvider = context.watch(); - return SmoothProductBaseCard( - backgroundColorOpacity: OPACITY, + final Widget widget = SmoothCard( + color: themeProvider.isLightTheme + ? Colors.grey.withOpacity(0.1) + : Colors.black, + padding: const EdgeInsets.symmetric( + vertical: MEDIUM_SPACE, + horizontal: LARGE_SPACE, + ), margin: const EdgeInsets.symmetric( + horizontal: 0.0, vertical: VERY_SMALL_SPACE, ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ SvgPicture.asset( Theme.of(context).brightness == Brightness.light - ? 'assets/app/release_icon_light_transparent_no_border.svg' - : 'assets/app/release_icon_dark_transparent_no_border.svg', - width: height * 0.2, - height: height * 0.2, - package: AppHelper.APP_PACKAGE, + ? 'assets/app/logo_text_black.svg' + : 'assets/app/logo_text_white.svg', + semanticsLabel: localizations.homepage_main_card_logo_description, ), - Padding( - padding: const EdgeInsets.only(top: MEDIUM_SPACE), - child: AutoSizeText( - localizations.welcomeToOpenFoodFacts, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 26.0, - fontWeight: FontWeight.bold, - height: 1.00, - ), - maxLines: 1, - ), + FormattedText( + text: localizations.homepage_main_card_subheading, + textAlign: TextAlign.center, + textStyle: const TextStyle(height: 1.3), ), - const Expanded(child: _SearchCardContent()), + const _SearchBar(), ], ), ); - } -} -class _SearchCardContent extends StatefulWidget { - const _SearchCardContent({ - Key? key, - }) : super(key: key); - - @override - State<_SearchCardContent> createState() => _SearchCardContentState(); -} - -class _SearchCardContentState extends State<_SearchCardContent> - with AutomaticKeepAliveClientMixin { - late _SearchCardContentType _content; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final UserPreferences preferences = context.read(); - final int scans = preferences.numberOfScans; - if (CameraHelper.hasACamera && scans < 1) { - _content = _SearchCardContentType.DEFAULT; - } else if (!preferences.inAppReviewAlreadyAsked && - Random().nextInt(10) == 0) { - _content = _SearchCardContentType.REVIEW_APP; + if (expandedMode) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.4, + ), + child: widget, + ); } else { - _content = _SearchCardContentType.TAG_LINE; + return widget; } } - - @override - Widget build(BuildContext context) { - super.build(context); - final ThemeData themeData = Theme.of(context); - final bool darkMode = themeData.brightness == Brightness.dark; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: VERY_SMALL_SPACE), - child: DefaultTextStyle.merge( - style: const TextStyle( - fontSize: LARGE_SPACE, - height: 1.22, - ), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - maxLines: 5, - child: Column( - children: [ - Expanded( - child: switch (_content) { - _SearchCardContentType.DEFAULT => - const _SearchCardContentDefault(), - _SearchCardContentType.TAG_LINE => - const _SearchCardContentTagLine(), - _SearchCardContentType.REVIEW_APP => - _SearchCardContentAppReview( - onHideReview: () { - setState(() => _content = _SearchCardContentType.DEFAULT); - }, - ), - }, - ), - if (_content != _SearchCardContentType.REVIEW_APP) - SearchField( - searchHelper: const SearchProductHelper(), - onFocus: () => _openSearchPage(context), - readOnly: true, - showClearButton: false, - backgroundColor: darkMode - ? Colors.white10 - : const Color.fromARGB(255, 240, 240, 240) - .withOpacity(SearchCard.OPACITY), - foregroundColor: themeData.colorScheme.onSurface - .withOpacity(SearchCard.OPACITY), - ), - ], - ), - ), - ); - } - - void _openSearchPage(BuildContext context) { - AppNavigator.of(context).push(AppRoutes.SEARCH); - } - - @override - bool get wantKeepAlive => true; } -enum _SearchCardContentType { - TAG_LINE, - REVIEW_APP, - DEFAULT, -} +class _SearchBar extends StatelessWidget { + const _SearchBar(); -class _SearchCardContentDefault extends StatelessWidget { - const _SearchCardContentDefault({Key? key}) : super(key: key); + static const double SEARCH_BAR_HEIGHT = 47.0; @override Widget build(BuildContext context) { final AppLocalizations localizations = AppLocalizations.of(context); + final ThemeProvider themeProvider = context.watch(); + final SmoothColorsThemeExtension theme = + Theme.of(context).extension()!; - return Center( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, - ), - child: AutoSizeText( - localizations.searchPanelHeader, + return SizedBox( + height: SEARCH_BAR_HEIGHT, + child: InkWell( + onTap: () => AppNavigator.of(context).push(AppRoutes.SEARCH), + borderRadius: BorderRadius.circular(30.0), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30.0), + color: themeProvider.isLightTheme ? Colors.white : theme.greyDark, + border: Border.all(color: theme.primaryBlack), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: 20.0, + end: 10.0, + bottom: 3.0, + ), + child: Text( + localizations.homepage_main_card_search_field_hint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: themeProvider.isLightTheme + ? Colors.black + : Colors.white, + ), + ), + ), + ), + AspectRatio( + aspectRatio: 1.0, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.primaryDark, + shape: BoxShape.circle, + ), + child: const Padding( + padding: EdgeInsets.all(10.0), + child: Search( + size: 20.0, + color: Colors.white, + ), + ), + ), + ) + ], + ), ), ), ); } } - -class _SearchCardContentTagLine extends StatelessWidget { - const _SearchCardContentTagLine(); - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: fetchTagLine(), - builder: (BuildContext context, AsyncSnapshot data) { - if (data.data != null) { - final TagLineItem tagLine = data.data!; - return InkWell( - borderRadius: ANGULAR_BORDER_RADIUS, - onTap: tagLine.hasLink - ? () async => LaunchUrlHelper.launchURL(tagLine.url) - : null, - child: Center( - child: AutoSizeText( - tagLine.message, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } else { - return const _SearchCardContentDefault(); - } - }, - ); - } -} - -class _SearchCardContentAppReview extends StatelessWidget { - const _SearchCardContentAppReview({ - required this.onHideReview, - }); - - final VoidCallback onHideReview; - - @override - Widget build(BuildContext context) { - final AppLocalizations localizations = AppLocalizations.of(context); - final UserPreferences preferences = context.read(); - - return Center( - child: OutlinedButtonTheme( - data: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - shape: const RoundedRectangleBorder( - borderRadius: ROUNDED_BORDER_RADIUS, - ), - side: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Spacer(), - const UserPreferencesListItemDivider( - margin: EdgeInsetsDirectional.only( - top: MEDIUM_SPACE, - bottom: SMALL_SPACE, - ), - ), - AutoSizeText( - localizations.tagline_app_review, - style: const TextStyle( - fontSize: 16.0, - ), - ), - const SizedBox(height: SMALL_SPACE), - SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () async { - if (await ApplicationStore.openAppReview()) { - await preferences.markInAppReviewAsShown(); - onHideReview.call(); - } - }, - style: OutlinedButton.styleFrom( - padding: const EdgeInsetsDirectional.symmetric( - vertical: SMALL_SPACE, - ), - ), - child: Text( - localizations.tagline_app_review_button_positive, - style: const TextStyle(fontSize: 17.0), - textAlign: TextAlign.center, - ), - ), - ), - const SizedBox(height: VERY_SMALL_SPACE), - IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () async { - preferences.markInAppReviewAsShown(); - await _showNegativeDialog(context, localizations); - onHideReview(); - }, - child: Text( - localizations.tagline_app_review_button_negative, - textAlign: TextAlign.center, - ), - ), - ), - const SizedBox(width: VERY_SMALL_SPACE), - Expanded( - child: OutlinedButton( - onPressed: () => onHideReview(), - child: Text( - localizations.tagline_app_review_button_later, - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - ), - const Spacer(), - ], - ), - ), - ); - } - - Future _showNegativeDialog( - BuildContext context, - AppLocalizations localizations, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return SmoothAlertDialog( - title: localizations.app_review_negative_modal_title, - body: Padding( - padding: const EdgeInsetsDirectional.only( - start: SMALL_SPACE, - end: SMALL_SPACE, - bottom: MEDIUM_SPACE, - ), - child: Text( - localizations.app_review_negative_modal_text, - textAlign: TextAlign.center, - ), - ), - positiveAction: SmoothActionButton( - text: localizations.app_review_negative_modal_positive_button, - onPressed: () { - final String formLink = UserFeedbackHelper.getFeedbackFormLink(); - LaunchUrlHelper.launchURL(formLink); - Navigator.of(context).pop(); - }, - ), - negativeAction: SmoothActionButton( - text: localizations.app_review_negative_modal_negative_button, - onPressed: () => Navigator.of(context).pop(), - ), - actionsAxis: Axis.vertical, - ); - }, - ); - } -} diff --git a/packages/smooth_app/lib/widgets/smooth_scaffold.dart b/packages/smooth_app/lib/widgets/smooth_scaffold.dart index 1943347dcd..ae5b4c73d2 100644 --- a/packages/smooth_app/lib/widgets/smooth_scaffold.dart +++ b/packages/smooth_app/lib/widgets/smooth_scaffold.dart @@ -167,6 +167,7 @@ class SmoothScaffoldState extends ScaffoldState { statusBarBrightness: Brightness.light, systemNavigationBarContrastEnforced: false, ); + case Brightness.light: default: return const SystemUiOverlayStyle(