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 @@
+
+
\ 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 @@
+
+
\ 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(