diff --git a/packages/smooth_app/lib/data_models/news_feed/newsfeed_provider.dart b/packages/smooth_app/lib/data_models/news_feed/newsfeed_provider.dart index 5b4e157d0ee..bbcf9acaf04 100644 --- a/packages/smooth_app/lib/data_models/news_feed/newsfeed_provider.dart +++ b/packages/smooth_app/lib/data_models/news_feed/newsfeed_provider.dart @@ -26,12 +26,12 @@ class AppNewsProvider extends ChangeNotifier { AppNewsProvider(UserPreferences preferences) : _state = const AppNewsStateLoading(), _preferences = preferences, + _uriOverride = preferences.getDevModeString( + UserPreferencesDevMode.userPreferencesCustomNewsJSONURI), _domain = preferences.getDevModeString( - UserPreferencesDevMode.userPreferencesTestEnvDomain) ?? - '', + UserPreferencesDevMode.userPreferencesTestEnvDomain), _prodEnv = preferences - .getFlag(UserPreferencesDevMode.userPreferencesFlagProd) ?? - true { + .getFlag(UserPreferencesDevMode.userPreferencesFlagProd) { _preferences.addListener(_onPreferencesChanged); loadLatestNews(); } @@ -67,13 +67,13 @@ class AppNewsProvider extends ChangeNotifier { return; } - final AppNews? tagLine = await Isolate.run( + final AppNews? appNews = await Isolate.run( () => _parseJSONAndGetLocalizedContent(jsonString!, locale)); - if (tagLine == null) { + if (appNews == null) { _emit(const AppNewsStateError('Unable to parse the JSON news file')); Logs.e('Unable to parse the JSON news file'); } else { - _emit(AppNewsStateLoaded(tagLine)); + _emit(AppNewsStateLoaded(appNews, cacheFile.lastModifiedSync())); Logs.i('News ${forceUpdate ? 're' : ''}loaded'); } } @@ -106,7 +106,13 @@ class AppNewsProvider extends ChangeNotifier { try { final UriProductHelper uriProductHelper = ProductQuery.uriProductHelper; final Map headers = {}; - final Uri uri = uriProductHelper.getUri(path: _newsUrl); + final Uri uri; + + if (_uriOverride?.isNotEmpty == true) { + uri = Uri.parse(_uriOverride!); + } else { + uri = uriProductHelper.getUri(path: _newsUrl); + } if (uriProductHelper.userInfoForPatch != null) { headers['Authorization'] = @@ -158,10 +164,14 @@ class AppNewsProvider extends ChangeNotifier { bool? _prodEnv; String? _domain; + String? _uriOverride; /// [ProductQuery.uriProductHelper] is not synced yet, /// so we have to check it manually Future _onPreferencesChanged() async { + final String jsonURI = _preferences.getDevModeString( + UserPreferencesDevMode.userPreferencesCustomNewsJSONURI) ?? + ''; final String domain = _preferences.getDevModeString( UserPreferencesDevMode.userPreferencesTestEnvDomain) ?? ''; @@ -169,9 +179,10 @@ class AppNewsProvider extends ChangeNotifier { _preferences.getFlag(UserPreferencesDevMode.userPreferencesFlagProd) ?? true; - if (domain != _domain || prodEnv != _prodEnv) { + if (domain != _domain || prodEnv != _prodEnv || jsonURI != _uriOverride) { _domain = domain; _prodEnv = prodEnv; + _uriOverride = jsonURI; loadLatestNews(forceUpdate: true); } } @@ -192,9 +203,10 @@ final class AppNewsStateLoading extends AppNewsState { } class AppNewsStateLoaded extends AppNewsState { - const AppNewsStateLoaded(this.content); + const AppNewsStateLoaded(this.content, this.lastUpdate); final AppNews content; + final DateTime lastUpdate; } class AppNewsStateError extends AppNewsState { diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb index 2de8ee5e6cc..ce55b2f9466 100644 --- a/packages/smooth_app/lib/l10n/app_en.arb +++ b/packages/smooth_app/lib/l10n/app_en.arb @@ -1716,6 +1716,31 @@ "@dev_preferences_import_history_subtitle": { "description": "User dev preferences - Import history - Subtitle" }, + "dev_preferences_news_custom_url_title": "Custom URL for news", + "@dev_preferences_news_custom_url_title": { + "description": "News dev preferences - Custom URL for news - Title" + }, + "dev_preferences_news_custom_url_subtitle": "URL of the JSON file:", + "@dev_preferences_news_custom_url_subtitle": { + "description": "News dev preferences - Custom URL for news - Title" + }, + "dev_preferences_news_custom_url_empty_value": "Not set", + "@dev_preferences_news_custom_url_empty_value": { + "description": "Message to show when the custom news URL is not set" + }, + "dev_preferences_news_provider_status_title": "Status", + "@dev_preferences_news_provider_status_title": { + "description": "News dev preferences - Status - Title" + }, + "dev_preferences_news_provider_status_subtitle": "Last refresh: {date}", + "@dev_preferences_news_provider_status_subtitle": { + "description": "News dev preferences - Custom URL for news - Subtitle", + "placeholders": { + "date": { + "type": "String" + } + } + }, "prices_app_dev_mode_flag": "Shortcut to Prices app on product page", "prices_app_button": "Go to Prices app", "prices_generic_title": "Prices", @@ -1824,6 +1849,7 @@ "description": "User dev preferences - Import history - Result successful" }, "dev_mode_section_server": "Server configuration", + "dev_mode_section_news": "News provider configuration", "dev_mode_section_product_page": "Product page", "dev_mode_section_ui": "User Interface", "dev_mode_section_data": "Data", diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart index b6b83651a91..f14ae46d4f6 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_dev_mode.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart'; import 'package:openfoodfacts/openfoodfacts.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/background/background_task_badge.dart'; import 'package:smooth_app/background/background_task_language_refresh.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; +import 'package:smooth_app/data_models/news_feed/newsfeed_provider.dart'; import 'package:smooth_app/data_models/preferences/user_preferences.dart'; import 'package:smooth_app/data_models/product_list.dart'; import 'package:smooth_app/database/dao_osm_location.dart'; @@ -59,6 +61,7 @@ class UserPreferencesDevMode extends AbstractUserPreferences { static const String userPreferencesFlagUserOrderedKP = '__userOrderedKP'; static const String userPreferencesFlagSpellCheckerOnOcr = '__spellcheckerOcr'; + static const String userPreferencesCustomNewsJSONURI = '__newsJsonURI'; final TextEditingController _textFieldController = TextEditingController(); @@ -266,6 +269,49 @@ class UserPreferencesDevMode extends AbstractUserPreferences { ), onTap: () async => _changeTestEnvDomain(), ), + UserPreferencesItemSection( + label: appLocalizations.dev_mode_section_news, + ), + UserPreferencesEditableItemTile( + title: appLocalizations.dev_preferences_news_custom_url_title, + subtitleWithEmptyValue: + appLocalizations.dev_preferences_news_custom_url_empty_value, + dialogAction: + appLocalizations.dev_preferences_news_custom_url_subtitle, + value: userPreferences + .getDevModeString(userPreferencesCustomNewsJSONURI), + onNewValue: (String newUrl) => userPreferences.setDevModeString( + userPreferencesCustomNewsJSONURI, + newUrl, + ), + validator: (String value) => + value.isEmpty || Uri.tryParse(value) != null, + ), + UserPreferencesItemTileBuilder( + title: appLocalizations.dev_preferences_news_provider_status_title, + subtitleBuilder: (BuildContext context) { + return Consumer( + builder: (_, AppNewsProvider provider, __) { + return Text(switch (provider.state) { + AppNewsStateLoading() => 'Loading...', + AppNewsStateLoaded(lastUpdate: final DateTime date) => + appLocalizations + .dev_preferences_news_provider_status_subtitle( + DateFormat.yMd().format(date), + ), + AppNewsStateError(exception: final dynamic e) => 'Error $e', + }); + }); + }, + trailingBuilder: (BuildContext context) { + return IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => context + .read() + .loadLatestNews(forceUpdate: true), + ); + }, + ), UserPreferencesItemSection( label: appLocalizations.dev_mode_section_product_page, ), diff --git a/packages/smooth_app/lib/pages/preferences/user_preferences_widgets.dart b/packages/smooth_app/lib/pages/preferences/user_preferences_widgets.dart index 44be303dd28..735c70731a4 100644 --- a/packages/smooth_app/lib/pages/preferences/user_preferences_widgets.dart +++ b/packages/smooth_app/lib/pages/preferences/user_preferences_widgets.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; import 'package:smooth_app/generic_lib/bottom_sheets/smooth_bottom_sheet.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/pages/preferences/user_preferences_item.dart'; import 'package:smooth_app/themes/smooth_theme_colors.dart'; @@ -164,6 +167,35 @@ class UserPreferencesItemTile implements UserPreferencesItem { ); } +/// Same as [UserPreferencesItemTile] but with [WidgetBuilder]. +class UserPreferencesItemTileBuilder implements UserPreferencesItem { + const UserPreferencesItemTileBuilder({ + required this.title, + required this.subtitleBuilder, + this.onTap, + this.leadingBuilder, + this.trailingBuilder, + }); + + final String title; + final WidgetBuilder subtitleBuilder; + final VoidCallback? onTap; + final WidgetBuilder? leadingBuilder; + final WidgetBuilder? trailingBuilder; + + @override + List get labels => [title]; + + @override + WidgetBuilder get builder => (final BuildContext context) => ListTile( + title: Text(title), + subtitle: subtitleBuilder.call(context), + onTap: onTap, + leading: leadingBuilder?.call(context), + trailing: trailingBuilder?.call(context), + ); +} + class UserPreferencesItemSection implements UserPreferencesItem { const UserPreferencesItemSection({ required this.label, @@ -495,3 +527,128 @@ class UserPreferenceListTile extends StatelessWidget { ); } } + +class UserPreferencesEditableItemTile extends UserPreferencesItemTile { + const UserPreferencesEditableItemTile({ + required super.title, + required String dialogAction, + required this.onNewValue, + this.subtitleWithEmptyValue, + this.validator, + this.hint, + this.value, + }) : assert(dialogAction.length > 0), + super(subtitle: dialogAction); + + final String? value; + final String? hint; + final String? subtitleWithEmptyValue; + final bool Function(String)? validator; + final Function(String) onNewValue; + + @override + WidgetBuilder get builder => (BuildContext context) { + return ListTile( + title: Text(title), + subtitle: Text(value?.isNotEmpty == true + ? value! + : (subtitleWithEmptyValue ?? '-')), + onTap: () async => _showInputTextDialog(context), + ); + }; + + Future _showInputTextDialog(BuildContext context) async { + final TextEditingController controller = + TextEditingController(text: value ?? ''); + + final dynamic res = await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + final AppLocalizations appLocalizations = AppLocalizations.of(context); + + return ChangeNotifierProvider.value( + value: controller, + child: Consumer( + builder: + (BuildContext context, TextEditingController controller, _) { + return SmoothAlertDialog( + title: title, + close: true, + body: _UserPreferencesEditableDialogContent( + title: subtitle!, + hint: hint, + ), + positiveAction: SmoothActionButton( + text: appLocalizations.okay, + onPressed: validator?.call(controller.text) != false + ? () => Navigator.of(context).pop(controller.text) + : null, + ), + negativeAction: SmoothActionButton( + text: appLocalizations.cancel, + onPressed: () => Navigator.of(context).pop(), + ), + ); + }, + ), + ); + }, + ); + + if (res is String && res != value) { + onNewValue.call(res); + } + } +} + +class _UserPreferencesEditableDialogContent extends StatefulWidget { + const _UserPreferencesEditableDialogContent({ + required this.title, + this.hint, + }); + + final String title; + final String? hint; + + @override + State<_UserPreferencesEditableDialogContent> createState() => + _InputTextDialogBodyState(); +} + +class _InputTextDialogBodyState + extends State<_UserPreferencesEditableDialogContent> { + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.title), + const SizedBox(height: 10), + TextField( + controller: Provider.of(context), + autocorrect: false, + autofocus: true, + textInputAction: TextInputAction.send, + decoration: InputDecoration( + hintText: widget.hint, + suffix: Semantics( + button: true, + label: MaterialLocalizations.of(context).deleteButtonTooltip, + excludeSemantics: true, + child: InkWell( + onTap: () => context.read().clear(), + customBorder: const CircleBorder(), + child: const Padding( + padding: EdgeInsetsDirectional.all(SMALL_SPACE), + child: Icon(Icons.clear), + ), + ), + ), + ), + onSubmitted: (String value) => Navigator.of(context).pop(value), + ), + ], + ); + } +}