diff --git a/README.md b/README.md index 11b6fd6d..2335e7b0 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Dynamically control the mobile app's behavior and operational state directly fro - **Critical State Management:** Instantly activate a maintenance mode or enforce a mandatory app update for your users to handle operational issues or critical releases gracefully. - **Dynamic In-App Content:** Remotely manage the visibility and behavior of in-feed promotional prompts and user engagement elements. - **Tier-Based Feature Gating:** Define and enforce feature limits based on user roles, such as setting the maximum number of followed topics or saved headlines for different subscription levels. +- **Global Notification Control:** Remotely enable or disable the entire push notification system, switch between providers (e.g., Firebase, OneSignal), and toggle specific delivery types like breaking news or daily digests. > **Your Advantage:** Gain unparalleled agility to manage your live application. Ensure service stability, drive user actions, and configure business rules instantly, all from a centralized control panel. diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index 20281e82..ff746207 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuratio import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/view/tabs/advertisements_configuration_tab.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/view/tabs/feed_configuration_tab.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/view/tabs/general_configuration_tab.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/push_notification_settings_form.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/about_icon.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -30,7 +31,7 @@ class _AppConfigurationPageState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController(length: 4, vsync: this); context.read().add(const AppConfigurationLoaded()); } @@ -68,6 +69,7 @@ class _AppConfigurationPageState extends State Tab(text: l10n.generalTab), Tab(text: l10n.feedTab), Tab(text: l10n.advertisementsTab), + Tab(text: l10n.notificationsTab), ], ), ), @@ -156,6 +158,14 @@ class _AppConfigurationPageState extends State ); }, ), + PushNotificationSettingsForm( + remoteConfig: remoteConfig, + onConfigChanged: (newConfig) { + context.read().add( + AppConfigurationFieldChanged(remoteConfig: newConfig), + ); + }, + ), ], ); } diff --git a/lib/app_configuration/view/tabs/feed_configuration_tab.dart b/lib/app_configuration/view/tabs/feed_configuration_tab.dart index 3f6acc66..35e8c186 100644 --- a/lib/app_configuration/view/tabs/feed_configuration_tab.dart +++ b/lib/app_configuration/view/tabs/feed_configuration_tab.dart @@ -1,7 +1,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/feed_decorator_form.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/saved_feed_filters_limit_form.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/saved_filter_limits_section.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/user_preference_limits_form.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/feed_decorator_type_l10n.dart'; @@ -93,7 +93,7 @@ class _FeedConfigurationTabState extends State { const tileIndex = 1; return ExpansionTile( key: ValueKey('savedFeedFilterLimitsTile_$expandedIndex'), - title: Text(l10n.savedFeedFiltersLimitLabel), + title: Text(l10n.savedFeedFilterLimitsTitle), childrenPadding: const EdgeInsetsDirectional.only( start: AppSpacing.lg, top: AppSpacing.md, @@ -114,7 +114,7 @@ class _FeedConfigurationTabState extends State { ), ), const SizedBox(height: AppSpacing.lg), - SavedFeedFiltersLimitForm( + SavedFilterLimitsSection( remoteConfig: widget.remoteConfig, onConfigChanged: widget.onConfigChanged, ), diff --git a/lib/app_configuration/widgets/push_notification_settings_form.dart b/lib/app_configuration/widgets/push_notification_settings_form.dart new file mode 100644 index 00000000..f011fa95 --- /dev/null +++ b/lib/app_configuration/widgets/push_notification_settings_form.dart @@ -0,0 +1,156 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/push_notification_provider_l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/push_notification_subscription_delivery_type_l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template push_notification_settings_form} +/// A form widget for configuring push notification settings. +/// {@endtemplate} +class PushNotificationSettingsForm extends StatelessWidget { + /// {@macro push_notification_settings_form} + const PushNotificationSettingsForm({ + required this.remoteConfig, + required this.onConfigChanged, + super.key, + }); + + /// The current [RemoteConfig] object. + final RemoteConfig remoteConfig; + + /// Callback to notify parent of changes to the [RemoteConfig]. + final ValueChanged onConfigChanged; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final pushConfig = remoteConfig.pushNotificationConfig; + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Text(l10n.pushNotificationSystemStatusTitle), + subtitle: Text(l10n.pushNotificationSystemStatusDescription), + value: pushConfig.enabled, + onChanged: (value) { + onConfigChanged( + remoteConfig.copyWith( + pushNotificationConfig: pushConfig.copyWith(enabled: value), + ), + ); + }, + ), + const SizedBox(height: AppSpacing.lg), + _buildPrimaryProviderSection(context, l10n, pushConfig), + const SizedBox(height: AppSpacing.lg), + _buildDeliveryTypesSection(context, l10n, pushConfig), + ], + ), + ); + } + + Widget _buildPrimaryProviderSection( + BuildContext context, + AppLocalizations l10n, + PushNotificationConfig pushConfig, + ) { + return ExpansionTile( + title: Text(l10n.pushNotificationPrimaryProviderTitle), + childrenPadding: const EdgeInsetsDirectional.only( + start: AppSpacing.lg, + top: AppSpacing.md, + bottom: AppSpacing.md, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.pushNotificationPrimaryProviderDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + Align( + alignment: AlignmentDirectional.centerStart, + child: SegmentedButton( + segments: PushNotificationProvider.values + .map( + (provider) => ButtonSegment( + value: provider, + label: Text(provider.l10n(context)), + ), + ) + .toList(), + selected: {pushConfig.primaryProvider}, + onSelectionChanged: (newSelection) { + onConfigChanged( + remoteConfig.copyWith( + pushNotificationConfig: pushConfig.copyWith( + primaryProvider: newSelection.first, + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildDeliveryTypesSection( + BuildContext context, + AppLocalizations l10n, + PushNotificationConfig pushConfig, + ) { + return ExpansionTile( + title: Text(l10n.pushNotificationDeliveryTypesTitle), + childrenPadding: const EdgeInsetsDirectional.only( + start: AppSpacing.lg, + top: AppSpacing.md, + bottom: AppSpacing.md, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.pushNotificationDeliveryTypesDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + Column( + children: PushNotificationSubscriptionDeliveryType.values + .map( + (type) => SwitchListTile( + title: Text(type.l10n(context)), + value: pushConfig.deliveryConfigs[type] ?? false, + onChanged: (value) { + final newDeliveryConfigs = + Map< + PushNotificationSubscriptionDeliveryType, + bool + >.from( + pushConfig.deliveryConfigs, + ); + newDeliveryConfigs[type] = value; + onConfigChanged( + remoteConfig.copyWith( + pushNotificationConfig: pushConfig.copyWith( + deliveryConfigs: newDeliveryConfigs, + ), + ), + ); + }, + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/lib/app_configuration/widgets/saved_feed_filters_limit_form.dart b/lib/app_configuration/widgets/saved_feed_filters_limit_form.dart deleted file mode 100644 index d0bc2534..00000000 --- a/lib/app_configuration/widgets/saved_feed_filters_limit_form.dart +++ /dev/null @@ -1,183 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/app_config_form_fields.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_user_role_l10n.dart'; -import 'package:ui_kit/ui_kit.dart'; - -/// {@template saved_feed_filters_limit_form} -/// A form for configuring saved feed filter limits within the -/// [RemoteConfig]. -/// -/// This form provides fields to set the maximum number of saved filters -/// for guest, authenticated, and premium users. -/// {@endtemplate} -class SavedFeedFiltersLimitForm extends StatefulWidget { - /// {@macro saved_feed_filters_limit_form} - const SavedFeedFiltersLimitForm({ - required this.remoteConfig, - required this.onConfigChanged, - super.key, - }); - - /// The current [RemoteConfig] object. - final RemoteConfig remoteConfig; - - /// Callback to notify parent of changes to the [RemoteConfig]. - final ValueChanged onConfigChanged; - - @override - State createState() => - _SavedFeedFiltersLimitFormState(); -} - -class _SavedFeedFiltersLimitFormState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; - late final Map _controllers; - - @override - void initState() { - super.initState(); - _tabController = TabController( - length: AppUserRole.values.length, - vsync: this, - ); - _initializeControllers(); - } - - @override - void didUpdateWidget(covariant SavedFeedFiltersLimitForm oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.remoteConfig.userPreferenceConfig != - oldWidget.remoteConfig.userPreferenceConfig) { - _updateControllerValues(); - } - } - - void _initializeControllers() { - _controllers = { - for (final role in AppUserRole.values) - role: () { - final limitText = _getSavedFiltersLimit( - widget.remoteConfig.userPreferenceConfig, - role, - ).toString(); - return TextEditingController(text: limitText) - ..selection = TextSelection.collapsed(offset: limitText.length); - }(), - }; - } - - void _updateControllerValues() { - for (final role in AppUserRole.values) { - final newLimit = _getSavedFiltersLimit( - widget.remoteConfig.userPreferenceConfig, - role, - ).toString(); - final controller = _controllers[role]; - if (controller != null && controller.text != newLimit) { - controller - ..text = newLimit - ..selection = TextSelection.collapsed( - offset: newLimit.length, - ); - } - } - } - - @override - void dispose() { - _tabController.dispose(); - for (final controller in _controllers.values) { - controller.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: SizedBox( - height: kTextTabBarHeight, - child: TabBar( - controller: _tabController, - tabAlignment: TabAlignment.start, - isScrollable: true, - tabs: AppUserRole.values - .map((role) => Tab(text: role.l10n(context))) - .toList(), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 120, - child: TabBarView( - controller: _tabController, - children: AppUserRole.values.map((role) { - final config = widget.remoteConfig.userPreferenceConfig; - return AppConfigIntField( - label: l10n.savedFeedFiltersLimitLabel, - description: l10n.savedFeedFiltersLimitLabel, - value: _getSavedFiltersLimit(config, role), - onChanged: (value) { - widget.onConfigChanged( - widget.remoteConfig.copyWith( - userPreferenceConfig: _updateSavedFiltersLimit( - config, - value, - role, - ), - ), - ); - }, - controller: _controllers[role], - ); - }).toList(), - ), - ), - ], - ); - } - - /// Retrieves the saved filters limit for a given [AppUserRole]. - /// - /// This helper method abstracts the logic for accessing the correct limit - /// from the [UserPreferenceConfig] based on the provided [role]. - int _getSavedFiltersLimit(UserPreferenceConfig config, AppUserRole role) { - switch (role) { - case AppUserRole.guestUser: - return config.guestSavedFiltersLimit; - case AppUserRole.standardUser: - return config.authenticatedSavedFiltersLimit; - case AppUserRole.premiumUser: - return config.premiumSavedFiltersLimit; - } - } - - /// Updates the saved filters limit for a given [AppUserRole]. - /// - /// This helper method abstracts the logic for updating the correct limit - /// within the [UserPreferenceConfig] based on the provided [role] and [value]. - UserPreferenceConfig _updateSavedFiltersLimit( - UserPreferenceConfig config, - int value, - AppUserRole role, - ) { - switch (role) { - case AppUserRole.guestUser: - return config.copyWith(guestSavedFiltersLimit: value); - case AppUserRole.standardUser: - return config.copyWith(authenticatedSavedFiltersLimit: value); - case AppUserRole.premiumUser: - return config.copyWith(premiumSavedFiltersLimit: value); - } - } -} diff --git a/lib/app_configuration/widgets/saved_filter_limits_form.dart b/lib/app_configuration/widgets/saved_filter_limits_form.dart new file mode 100644 index 00000000..b5467566 --- /dev/null +++ b/lib/app_configuration/widgets/saved_filter_limits_form.dart @@ -0,0 +1,270 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/app_config_form_fields.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_user_role_l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/push_notification_subscription_delivery_type_l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// Defines the type of filter being configured. +enum SavedFilterType { headline, source } + +/// {@template saved_filter_limits_form} +/// A reusable form widget for configuring limits for saved filters. +/// +/// This widget is designed to handle the configuration for both headline and +/// source filters by using the [filterType] parameter. It displays a tabbed +/// interface for different user roles and provides fields for setting total, +/// pinned, and (if applicable) notification subscription limits. +/// {@endtemplate} +class SavedFilterLimitsForm extends StatefulWidget { + /// {@macro saved_filter_limits_form} + const SavedFilterLimitsForm({ + required this.remoteConfig, + required this.onConfigChanged, + required this.filterType, + super.key, + }); + + /// The current [RemoteConfig] object. + final RemoteConfig remoteConfig; + + /// Callback to notify parent of changes to the [RemoteConfig]. + final ValueChanged onConfigChanged; + + /// The type of filter to configure (headline or source). + final SavedFilterType filterType; + + @override + State createState() => _SavedFilterLimitsFormState(); +} + +class _SavedFilterLimitsFormState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // A nested map to hold controllers: Role -> Field -> Controller + final Map> _controllers = {}; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: AppUserRole.values.length, + vsync: this, + ); + _initializeControllers(); + } + + @override + void didUpdateWidget(covariant SavedFilterLimitsForm oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.remoteConfig.userPreferenceConfig != + oldWidget.remoteConfig.userPreferenceConfig) { + _updateControllerValues(); + } + } + + /// Initializes all TextEditingControllers based on the initial config. + void _initializeControllers() { + for (final role in AppUserRole.values) { + _controllers[role] = {}; + final limits = _getLimitsForRole(role); + + _controllers[role]!['total'] = _createController(limits.total.toString()); + _controllers[role]!['pinned'] = _createController( + limits.pinned.toString(), + ); + + if (widget.filterType == SavedFilterType.headline) { + for (final type in PushNotificationSubscriptionDeliveryType.values) { + final value = limits.notificationSubscriptions?[type] ?? 0; + _controllers[role]![type.name] = _createController(value.toString()); + } + } + } + } + + /// Creates a single TextEditingController with appropriate selection. + TextEditingController _createController(String text) { + return TextEditingController(text: text) + ..selection = TextSelection.collapsed(offset: text.length); + } + + /// Updates controller values if the remote config has changed. + void _updateControllerValues() { + for (final role in AppUserRole.values) { + final limits = _getLimitsForRole(role); + _updateControllerText(_controllers[role]!['total']!, limits.total); + _updateControllerText(_controllers[role]!['pinned']!, limits.pinned); + + if (widget.filterType == SavedFilterType.headline) { + for (final type in PushNotificationSubscriptionDeliveryType.values) { + final value = limits.notificationSubscriptions?[type] ?? 0; + _updateControllerText(_controllers[role]![type.name]!, value); + } + } + } + } + + /// Safely updates a controller's text and selection. + void _updateControllerText(TextEditingController controller, int value) { + final text = value.toString(); + if (controller.text != text) { + controller + ..text = text + ..selection = TextSelection.collapsed(offset: text.length); + } + } + + @override + void dispose() { + _tabController.dispose(); + for (final roleControllers in _controllers.values) { + for (final controller in roleControllers.values) { + controller.dispose(); + } + } + super.dispose(); + } + + /// Retrieves the correct [SavedFilterLimits] for a given role. + SavedFilterLimits _getLimitsForRole(AppUserRole role) { + final config = widget.remoteConfig.userPreferenceConfig; + final limitsMap = widget.filterType == SavedFilterType.headline + ? config.savedHeadlineFiltersLimit + : config.savedSourceFiltersLimit; + return limitsMap[role]!; + } + + /// Updates the remote config when a value changes. + void _onValueChanged(AppUserRole role, String field, int value) { + final config = widget.remoteConfig.userPreferenceConfig; + final isHeadline = widget.filterType == SavedFilterType.headline; + + // Create a mutable copy of the role-to-limits map. + final newLimitsMap = Map.from( + isHeadline + ? config.savedHeadlineFiltersLimit + : config.savedSourceFiltersLimit, + ); + + // Get the current limits for the role and create a modified copy. + final currentLimits = newLimitsMap[role]!; + final SavedFilterLimits newLimits; + + if (field == 'total') { + newLimits = currentLimits.copyWith(total: value); + } else if (field == 'pinned') { + newLimits = currentLimits.copyWith(pinned: value); + } else { + // This must be a notification subscription change. + final deliveryType = PushNotificationSubscriptionDeliveryType.values + .byName(field); + final newSubscriptions = + Map.from( + currentLimits.notificationSubscriptions ?? {}, + ); + newSubscriptions[deliveryType] = value; + newLimits = currentLimits.copyWith( + notificationSubscriptions: newSubscriptions, + ); + } + + // Update the map with the new limits for the role. + newLimitsMap[role] = newLimits; + + // Create the updated UserPreferenceConfig. + final newUserPreferenceConfig = isHeadline + ? config.copyWith(savedHeadlineFiltersLimit: newLimitsMap) + : config.copyWith(savedSourceFiltersLimit: newLimitsMap); + + // Notify the parent widget. + widget.onConfigChanged( + widget.remoteConfig.copyWith( + userPreferenceConfig: newUserPreferenceConfig, + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final isHeadlineFilter = widget.filterType == SavedFilterType.headline; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: SizedBox( + height: kTextTabBarHeight, + child: TabBar( + controller: _tabController, + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: AppUserRole.values + .map((role) => Tab(text: role.l10n(context))) + .toList(), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + // TabBarView to display role-specific fields + SizedBox( + // Adjust height based on whether notification fields are shown. + height: isHeadlineFilter ? 400 : 250, + child: TabBarView( + controller: _tabController, + children: AppUserRole.values.map((role) { + final limits = _getLimitsForRole(role); + return SingleChildScrollView( + child: Column( + children: [ + AppConfigIntField( + label: l10n.totalLimitLabel, + description: l10n.totalLimitDescription, + value: limits.total, + onChanged: (value) => + _onValueChanged(role, 'total', value), + controller: _controllers[role]!['total'], + ), + AppConfigIntField( + label: l10n.pinnedLimitLabel, + description: l10n.pinnedLimitDescription, + value: limits.pinned, + onChanged: (value) => + _onValueChanged(role, 'pinned', value), + controller: _controllers[role]!['pinned'], + ), + if (isHeadlineFilter) + ..._buildNotificationFields(l10n, role, limits), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } + + /// Builds the list of input fields for notification subscription limits. + List _buildNotificationFields( + AppLocalizations l10n, + AppUserRole role, + SavedFilterLimits limits, + ) { + return PushNotificationSubscriptionDeliveryType.values.map((type) { + final value = limits.notificationSubscriptions?[type] ?? 0; + return AppConfigIntField( + label: type.l10n(context), + description: l10n.notificationSubscriptionLimitDescription, + value: value, + onChanged: (newValue) => _onValueChanged(role, type.name, newValue), + controller: _controllers[role]![type.name], + ); + }).toList(); + } +} diff --git a/lib/app_configuration/widgets/saved_filter_limits_section.dart b/lib/app_configuration/widgets/saved_filter_limits_section.dart new file mode 100644 index 00000000..7c86bf83 --- /dev/null +++ b/lib/app_configuration/widgets/saved_filter_limits_section.dart @@ -0,0 +1,123 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/widgets/saved_filter_limits_form.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template saved_filter_limits_section} +/// A container widget for configuring both saved headline and source filter +/// limits within the [RemoteConfig]. +/// +/// This widget composes two [SavedFilterLimitsForm] instances, one for +/// headline filters and one for source filters, providing a unified interface +/// for managing these configurations. +/// {@endtemplate} +class SavedFilterLimitsSection extends StatefulWidget { + /// {@macro saved_filter_limits_section} + const SavedFilterLimitsSection({ + required this.remoteConfig, + required this.onConfigChanged, + super.key, + }); + + /// The current [RemoteConfig] object. + final RemoteConfig remoteConfig; + + /// Callback to notify parent of changes to the [RemoteConfig]. + final ValueChanged onConfigChanged; + + @override + State createState() => + _SavedFilterLimitsSectionState(); +} + +class _SavedFilterLimitsSectionState extends State + with SingleTickerProviderStateMixin { + /// Notifier for the index of the currently expanded top-level ExpansionTile. + /// + /// A value of `null` means no tile is expanded. + final ValueNotifier _expandedTileIndex = ValueNotifier(null); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + return Column( + children: [ + ValueListenableBuilder( + valueListenable: _expandedTileIndex, + builder: (context, expandedIndex, child) { + const tileIndex = 0; + return ExpansionTile( + key: ValueKey('savedHeadlineFilterLimitsTile_$expandedIndex'), + title: Text(l10n.savedHeadlineFilterLimitsTitle), + childrenPadding: const EdgeInsetsDirectional.only( + start: AppSpacing.lg, + top: AppSpacing.md, + bottom: AppSpacing.md, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + onExpansionChanged: (isExpanded) { + _expandedTileIndex.value = isExpanded ? tileIndex : null; + }, + initiallyExpanded: expandedIndex == tileIndex, + children: [ + Text( + l10n.savedHeadlineFilterLimitsDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + SavedFilterLimitsForm( + remoteConfig: widget.remoteConfig, + onConfigChanged: widget.onConfigChanged, + filterType: SavedFilterType.headline, + ), + ], + ); + }, + ), + const SizedBox(height: AppSpacing.lg), + ValueListenableBuilder( + valueListenable: _expandedTileIndex, + builder: (context, expandedIndex, child) { + const tileIndex = 1; + return ExpansionTile( + key: ValueKey('savedSourceFilterLimitsTile_$expandedIndex'), + title: Text(l10n.savedSourceFilterLimitsTitle), + childrenPadding: const EdgeInsetsDirectional.only( + start: AppSpacing.lg, + top: AppSpacing.md, + bottom: AppSpacing.md, + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + onExpansionChanged: (isExpanded) { + _expandedTileIndex.value = isExpanded ? tileIndex : null; + }, + initiallyExpanded: expandedIndex == tileIndex, + children: [ + Text( + l10n.savedSourceFilterLimitsDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + ), + const SizedBox(height: AppSpacing.lg), + SavedFilterLimitsForm( + remoteConfig: widget.remoteConfig, + onConfigChanged: widget.onConfigChanged, + filterType: SavedFilterType.source, + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/app_configuration/widgets/user_preference_limits_form.dart b/lib/app_configuration/widgets/user_preference_limits_form.dart index 6bfca60d..03be00d0 100644 --- a/lib/app_configuration/widgets/user_preference_limits_form.dart +++ b/lib/app_configuration/widgets/user_preference_limits_form.dart @@ -274,55 +274,53 @@ class _UserPreferenceLimitsFormState extends State } } + /// Retrieves the followed items limit for a given [AppUserRole] from the map. int _getFollowedItemsLimit(UserPreferenceConfig config, AppUserRole role) { - switch (role) { - case AppUserRole.guestUser: - return config.guestFollowedItemsLimit; - case AppUserRole.standardUser: - return config.authenticatedFollowedItemsLimit; - case AppUserRole.premiumUser: - return config.premiumFollowedItemsLimit; - } + // The '!' is safe as the model guarantees a value for every role. + return config.followedItemsLimit[role]!; } + /// Retrieves the saved headlines limit for a given [AppUserRole] from the map. int _getSavedHeadlinesLimit(UserPreferenceConfig config, AppUserRole role) { - switch (role) { - case AppUserRole.guestUser: - return config.guestSavedHeadlinesLimit; - case AppUserRole.standardUser: - return config.authenticatedSavedHeadlinesLimit; - case AppUserRole.premiumUser: - return config.premiumSavedHeadlinesLimit; - } + // The '!' is safe as the model guarantees a value for every role. + return config.savedHeadlinesLimit[role]!; } + /// Creates an updated [UserPreferenceConfig] with a new followed items limit + /// for a specific [AppUserRole]. + /// + /// This method creates a mutable copy of the existing map, updates the value + /// for the specified role, and then returns a new `UserPreferenceConfig` + /// with the updated map. UserPreferenceConfig _updateFollowedItemsLimit( UserPreferenceConfig config, int value, AppUserRole role, ) { - switch (role) { - case AppUserRole.guestUser: - return config.copyWith(guestFollowedItemsLimit: value); - case AppUserRole.standardUser: - return config.copyWith(authenticatedFollowedItemsLimit: value); - case AppUserRole.premiumUser: - return config.copyWith(premiumFollowedItemsLimit: value); - } + // Create a mutable copy of the map to avoid modifying the original state. + final newLimits = Map.from(config.followedItemsLimit); + // Update the value for the specified role. + newLimits[role] = value; + // Return a new config object with the updated map. + return config.copyWith(followedItemsLimit: newLimits); } + /// Creates an updated [UserPreferenceConfig] with a new saved headlines limit + /// for a specific [AppUserRole]. + /// + /// This method creates a mutable copy of the existing map, updates the value + /// for the specified role, and then returns a new `UserPreferenceConfig` + /// with the updated map. UserPreferenceConfig _updateSavedHeadlinesLimit( UserPreferenceConfig config, int value, AppUserRole role, ) { - switch (role) { - case AppUserRole.guestUser: - return config.copyWith(guestSavedHeadlinesLimit: value); - case AppUserRole.standardUser: - return config.copyWith(authenticatedSavedHeadlinesLimit: value); - case AppUserRole.premiumUser: - return config.copyWith(premiumSavedHeadlinesLimit: value); - } + // Create a mutable copy of the map to avoid modifying the original state. + final newLimits = Map.from(config.savedHeadlinesLimit); + // Update the value for the specified role. + newLimits[role] = value; + // Return a new config object with the updated map. + return config.copyWith(savedHeadlinesLimit: newLimits); } } diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index 704e948d..a312c5c4 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -7,6 +7,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -153,6 +154,17 @@ class ContentManagementBloc filter['eventCountry.id'] = {r'$in': state.selectedCountryIds}; } + // Handle the breaking news filter based on the enum status. + switch (state.isBreaking) { + case BreakingNewsFilterStatus.breakingOnly: + filter['isBreaking'] = true; + case BreakingNewsFilterStatus.nonBreakingOnly: + filter['isBreaking'] = false; + case BreakingNewsFilterStatus.all: + // For 'all', we don't add the 'isBreaking' key to the filter. + break; + } + return filter; } diff --git a/lib/content_management/bloc/create_headline/create_headline_bloc.dart b/lib/content_management/bloc/create_headline/create_headline_bloc.dart index 50fc11ce..c912cfa5 100644 --- a/lib/content_management/bloc/create_headline/create_headline_bloc.dart +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -23,6 +23,7 @@ class CreateHeadlineBloc on(_onSourceChanged); on(_onTopicChanged); on(_onCountryChanged); + on(_onIsBreakingChanged); on(_onSavedAsDraft); on(_onPublished); } @@ -80,6 +81,13 @@ class CreateHeadlineBloc emit(state.copyWith(eventCountry: () => event.country)); } + void _onIsBreakingChanged( + CreateHeadlineIsBreakingChanged event, + Emitter emit, + ) { + emit(state.copyWith(isBreaking: event.isBreaking)); + } + /// Handles saving the headline as a draft. Future _onSavedAsDraft( CreateHeadlineSavedAsDraft event, @@ -100,6 +108,7 @@ class CreateHeadlineBloc createdAt: now, updatedAt: now, status: ContentStatus.draft, + isBreaking: state.isBreaking, ); await _headlinesRepository.create(item: newHeadline); @@ -141,6 +150,7 @@ class CreateHeadlineBloc createdAt: now, updatedAt: now, status: ContentStatus.active, + isBreaking: state.isBreaking, ); await _headlinesRepository.create(item: newHeadline); diff --git a/lib/content_management/bloc/create_headline/create_headline_event.dart b/lib/content_management/bloc/create_headline/create_headline_event.dart index dfe8af99..ddd08832 100644 --- a/lib/content_management/bloc/create_headline/create_headline_event.dart +++ b/lib/content_management/bloc/create_headline/create_headline_event.dart @@ -73,3 +73,12 @@ final class CreateHeadlineSavedAsDraft extends CreateHeadlineEvent { final class CreateHeadlinePublished extends CreateHeadlineEvent { const CreateHeadlinePublished(); } + +/// Event for when the headline's breaking news status is changed. +final class CreateHeadlineIsBreakingChanged extends CreateHeadlineEvent { + const CreateHeadlineIsBreakingChanged(this.isBreaking); + + final bool isBreaking; + @override + List get props => [isBreaking]; +} diff --git a/lib/content_management/bloc/create_headline/create_headline_state.dart b/lib/content_management/bloc/create_headline/create_headline_state.dart index fccbd476..97c06d0c 100644 --- a/lib/content_management/bloc/create_headline/create_headline_state.dart +++ b/lib/content_management/bloc/create_headline/create_headline_state.dart @@ -30,6 +30,7 @@ final class CreateHeadlineState extends Equatable { this.topic, this.eventCountry, this.exception, + this.isBreaking = false, this.createdHeadline, }); @@ -42,6 +43,7 @@ final class CreateHeadlineState extends Equatable { final Topic? topic; final Country? eventCountry; final HttpException? exception; + final bool isBreaking; final Headline? createdHeadline; /// Returns true if the form is valid and can be submitted. @@ -52,7 +54,8 @@ final class CreateHeadlineState extends Equatable { imageUrl.isNotEmpty && source != null && topic != null && - eventCountry != null; + eventCountry != null && + !isBreaking; // If breaking, it must be published, not drafted. CreateHeadlineState copyWith({ CreateHeadlineStatus? status, @@ -63,6 +66,7 @@ final class CreateHeadlineState extends Equatable { ValueGetter? source, ValueGetter? topic, ValueGetter? eventCountry, + bool? isBreaking, HttpException? exception, Headline? createdHeadline, }) { @@ -75,6 +79,7 @@ final class CreateHeadlineState extends Equatable { source: source != null ? source() : this.source, topic: topic != null ? topic() : this.topic, eventCountry: eventCountry != null ? eventCountry() : this.eventCountry, + isBreaking: isBreaking ?? this.isBreaking, exception: exception, createdHeadline: createdHeadline ?? this.createdHeadline, ); @@ -90,6 +95,7 @@ final class CreateHeadlineState extends Equatable { source, topic, eventCountry, + isBreaking, exception, createdHeadline, ]; diff --git a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart index d57dba7b..e89da9e8 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -28,6 +28,7 @@ class EditHeadlineBloc extends Bloc { on(_onSourceChanged); on(_onTopicChanged); on(_onCountryChanged); + on(_onIsBreakingChanged); on(_onSavedAsDraft); on(_onPublished); @@ -52,6 +53,7 @@ class EditHeadlineBloc extends Bloc { source: () => headline.source, topic: () => headline.topic, eventCountry: () => headline.eventCountry, + isBreaking: headline.isBreaking, ), ); } on HttpException catch (e) { @@ -142,6 +144,18 @@ class EditHeadlineBloc extends Bloc { ); } + void _onIsBreakingChanged( + EditHeadlineIsBreakingChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + isBreaking: event.isBreaking, + status: EditHeadlineStatus.initial, + ), + ); + } + /// Handles saving the headline as a draft. Future _onSavedAsDraft( EditHeadlineSavedAsDraft event, @@ -160,6 +174,7 @@ class EditHeadlineBloc extends Bloc { source: state.source, topic: state.topic, eventCountry: state.eventCountry, + isBreaking: state.isBreaking, status: ContentStatus.draft, updatedAt: DateTime.now(), ); @@ -204,6 +219,7 @@ class EditHeadlineBloc extends Bloc { source: state.source, topic: state.topic, eventCountry: state.eventCountry, + isBreaking: state.isBreaking, status: ContentStatus.active, updatedAt: DateTime.now(), ); diff --git a/lib/content_management/bloc/edit_headline/edit_headline_event.dart b/lib/content_management/bloc/edit_headline/edit_headline_event.dart index 273d6edf..fa9b1a15 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_event.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_event.dart @@ -78,3 +78,12 @@ final class EditHeadlineSavedAsDraft extends EditHeadlineEvent { final class EditHeadlinePublished extends EditHeadlineEvent { const EditHeadlinePublished(); } + +/// Event for when the headline's breaking news status is changed. +final class EditHeadlineIsBreakingChanged extends EditHeadlineEvent { + const EditHeadlineIsBreakingChanged(this.isBreaking); + + final bool isBreaking; + @override + List get props => [isBreaking]; +} diff --git a/lib/content_management/bloc/edit_headline/edit_headline_state.dart b/lib/content_management/bloc/edit_headline/edit_headline_state.dart index 743953b4..87667319 100644 --- a/lib/content_management/bloc/edit_headline/edit_headline_state.dart +++ b/lib/content_management/bloc/edit_headline/edit_headline_state.dart @@ -31,6 +31,7 @@ final class EditHeadlineState extends Equatable { this.topic, this.eventCountry, this.exception, + this.isBreaking = false, this.updatedHeadline, }); @@ -44,6 +45,7 @@ final class EditHeadlineState extends Equatable { final Topic? topic; final Country? eventCountry; final HttpException? exception; + final bool isBreaking; final Headline? updatedHeadline; /// Returns true if the form is valid and can be submitted. @@ -57,6 +59,9 @@ final class EditHeadlineState extends Equatable { topic != null && eventCountry != null; + // isBreaking is not part of form validity for editing, as it doesn't + // trigger new notifications on update. + EditHeadlineState copyWith({ EditHeadlineStatus? status, String? headlineId, @@ -67,6 +72,7 @@ final class EditHeadlineState extends Equatable { ValueGetter? source, ValueGetter? topic, ValueGetter? eventCountry, + bool? isBreaking, HttpException? exception, Headline? updatedHeadline, }) { @@ -80,6 +86,7 @@ final class EditHeadlineState extends Equatable { source: source != null ? source() : this.source, topic: topic != null ? topic() : this.topic, eventCountry: eventCountry != null ? eventCountry() : this.eventCountry, + isBreaking: isBreaking ?? this.isBreaking, exception: exception, updatedHeadline: updatedHeadline ?? this.updatedHeadline, ); @@ -96,6 +103,7 @@ final class EditHeadlineState extends Equatable { source, topic, eventCountry, + isBreaking, exception, updatedHeadline, ]; diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart index e0a14b54..528673c2 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart @@ -1,6 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; part 'headlines_filter_event.dart'; part 'headlines_filter_state.dart'; @@ -21,6 +22,9 @@ class HeadlinesFilterBloc on(_onHeadlinesSourceFilterChanged); on(_onHeadlinesTopicFilterChanged); on(_onHeadlinesCountryFilterChanged); + on( + _onHeadlinesBreakingNewsFilterChanged, + ); on(_onHeadlinesFilterApplied); on(_onHeadlinesFilterReset); } @@ -73,6 +77,17 @@ class HeadlinesFilterBloc emit(state.copyWith(selectedCountryIds: event.countryIds)); } + /// Handles changes to the breaking news filter. + /// + /// This updates the `isBreaking` status for the filter using the + /// [BreakingNewsFilterStatus] enum. + void _onHeadlinesBreakingNewsFilterChanged( + HeadlinesBreakingNewsFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(isBreaking: event.isBreaking)); + } + /// Handles the application of all current filter settings. /// /// This event is dispatched when the user explicitly confirms the filters @@ -89,6 +104,7 @@ class HeadlinesFilterBloc selectedSourceIds: event.selectedSourceIds, selectedTopicIds: event.selectedTopicIds, selectedCountryIds: event.selectedCountryIds, + isBreaking: event.isBreaking, ), ); } diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart index d4299379..abd400b1 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart @@ -1,5 +1,6 @@ part of 'headlines_filter_bloc.dart'; +/// Base class for all events related to the [HeadlinesFilterBloc]. sealed class HeadlinesFilterEvent extends Equatable { const HeadlinesFilterEvent(); @@ -57,6 +58,16 @@ final class HeadlinesCountryFilterChanged extends HeadlinesFilterEvent { List get props => [countryIds]; } +/// Event to notify the BLoC that the breaking news filter has changed. +final class HeadlinesBreakingNewsFilterChanged extends HeadlinesFilterEvent { + const HeadlinesBreakingNewsFilterChanged(this.isBreaking); + + final BreakingNewsFilterStatus isBreaking; + + @override + List get props => [isBreaking]; +} + /// Event to request applying all current filters. final class HeadlinesFilterApplied extends HeadlinesFilterEvent { const HeadlinesFilterApplied({ @@ -65,6 +76,7 @@ final class HeadlinesFilterApplied extends HeadlinesFilterEvent { required this.selectedSourceIds, required this.selectedTopicIds, required this.selectedCountryIds, + required this.isBreaking, }); final String searchQuery; @@ -72,6 +84,7 @@ final class HeadlinesFilterApplied extends HeadlinesFilterEvent { final List selectedSourceIds; final List selectedTopicIds; final List selectedCountryIds; + final BreakingNewsFilterStatus isBreaking; @override List get props => [ @@ -80,6 +93,7 @@ final class HeadlinesFilterApplied extends HeadlinesFilterEvent { selectedSourceIds, selectedTopicIds, selectedCountryIds, + isBreaking, ]; } diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart index 7017a7ab..dcbb9e5e 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart @@ -15,6 +15,7 @@ class HeadlinesFilterState extends Equatable { this.selectedSourceIds = const [], this.selectedTopicIds = const [], this.selectedCountryIds = const [], + this.isBreaking = BreakingNewsFilterStatus.all, }); /// The current text in the search query field. @@ -32,6 +33,10 @@ class HeadlinesFilterState extends Equatable { /// The list of country IDs to be included in the filter. final List selectedCountryIds; + /// The breaking news status to filter by. + /// `null` = all, `true` = breaking only, `false` = non-breaking only. + final BreakingNewsFilterStatus isBreaking; + /// Creates a copy of this state with the given fields replaced with the /// new values. HeadlinesFilterState copyWith({ @@ -40,6 +45,7 @@ class HeadlinesFilterState extends Equatable { List? selectedSourceIds, List? selectedTopicIds, List? selectedCountryIds, + BreakingNewsFilterStatus? isBreaking, }) { return HeadlinesFilterState( searchQuery: searchQuery ?? this.searchQuery, @@ -47,15 +53,17 @@ class HeadlinesFilterState extends Equatable { selectedSourceIds: selectedSourceIds ?? this.selectedSourceIds, selectedTopicIds: selectedTopicIds ?? this.selectedTopicIds, selectedCountryIds: selectedCountryIds ?? this.selectedCountryIds, + isBreaking: isBreaking ?? this.isBreaking, ); } @override - List get props => [ + List get props => [ searchQuery, selectedStatus, selectedSourceIds, selectedTopicIds, selectedCountryIds, + isBreaking, ]; } diff --git a/lib/content_management/models/breaking_news_filter_status.dart b/lib/content_management/models/breaking_news_filter_status.dart new file mode 100644 index 00000000..f818ead8 --- /dev/null +++ b/lib/content_management/models/breaking_news_filter_status.dart @@ -0,0 +1,11 @@ +/// Defines the status of the breaking news filter. +enum BreakingNewsFilterStatus { + /// Show all headlines, regardless of their breaking news status. + all, + + /// Show only headlines marked as breaking news. + breakingOnly, + + /// Show only headlines not marked as breaking news. + nonBreakingOnly, +} diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index fecb7144..7f5c7ed2 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -78,7 +78,8 @@ class _ContentManagementPageState extends State !const DeepCollectionEquality().equals( previous.selectedCountryIds, current.selectedCountryIds, - ), + ) || + previous.isBreaking != current.isBreaking, listener: (context, state) { context.read().add( LoadHeadlinesRequested( diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index ced81804..0cdd7f39 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -3,6 +3,7 @@ import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/create_headline/create_headline_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:go_router/go_router.dart'; @@ -70,11 +71,17 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { content: Text(l10n.saveHeadlineMessage), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(ContentStatus.draft), + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(ContentStatus.draft); + }, child: Text(l10n.saveAsDraft), ), TextButton( - onPressed: () => Navigator.of(context).pop(ContentStatus.active), + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(ContentStatus.active); + }, child: Text(l10n.publish), ), ], @@ -105,23 +112,8 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { return IconButton( icon: const Icon(Icons.save), tooltip: l10n.saveChanges, - onPressed: state.isFormValid - ? () async { - final selectedStatus = await _showSaveOptionsDialog( - context, - ); - if (selectedStatus == ContentStatus.active && - context.mounted) { - context.read().add( - const CreateHeadlinePublished(), - ); - } else if (selectedStatus == ContentStatus.draft && - context.mounted) { - context.read().add( - const CreateHeadlineSavedAsDraft(), - ); - } - } + onPressed: _isSaveButtonEnabled(state) + ? () => _handleSave(context, state, l10n) : null, ); }, @@ -206,6 +198,25 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { .add(CreateHeadlineImageUrlChanged(value)), ), const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: Text( + l10n.isBreakingNewsLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Switch( + value: state.isBreaking, + onChanged: (value) => context + .read() + .add(CreateHeadlineIsBreakingChanged(value)), + ), + ], + ), + Text(l10n.isBreakingNewsDescription), + const SizedBox(height: AppSpacing.lg), + // Existing SearchableSelectionInput widgets SearchableSelectionInput( label: l10n.sourceName, selectedItems: state.source != null @@ -305,4 +316,107 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { ), ); } + + /// Determines if the save button should be enabled. + /// + /// The button is enabled if the form is valid for drafting, or if all + /// fields are filled for publishing (regardless of breaking news status). + bool _isSaveButtonEnabled(CreateHeadlineState state) { + final allFieldsFilled = + state.title.isNotEmpty && + state.excerpt.isNotEmpty && + state.url.isNotEmpty && + state.imageUrl.isNotEmpty && + state.source != null && + state.topic != null && + state.eventCountry != null; + + return allFieldsFilled; + } + + /// Handles the save logic, including showing save options and the + /// confirmation dialog for breaking news. + Future _handleSave( + BuildContext context, + CreateHeadlineState state, + AppLocalizations l10n, + ) async { + final selectedStatus = await _showSaveOptionsDialog(context); + + // If the user cancels the dialog, do nothing. + if (selectedStatus == null) return; + + // If the user tries to save as draft but it's breaking news, show an error. + if (selectedStatus == ContentStatus.draft && state.isBreaking) { + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.invalidFormTitle), + content: Text(l10n.cannotDraftBreakingNews), + actions: [ + TextButton( + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: Text(l10n.ok), + ), + ], + ), + ); + return; + } + + // If publishing as breaking news, show an extra confirmation. + if (selectedStatus == ContentStatus.active && state.isBreaking) { + if (!context.mounted) return; + final confirmBreaking = await _showBreakingNewsConfirmationDialog( + context, + l10n, + ); + if (confirmBreaking != true) return; // If not confirmed, do nothing. + } + + // Dispatch the appropriate event based on user's choice. + if (selectedStatus == ContentStatus.active) { + if (!context.mounted) return; + context.read().add(const CreateHeadlinePublished()); + } else if (selectedStatus == ContentStatus.draft) { + if (!context.mounted) return; + context.read().add( + const CreateHeadlineSavedAsDraft(), + ); + } + } + + /// Shows a confirmation dialog specifically for publishing breaking news. + Future _showBreakingNewsConfirmationDialog( + BuildContext context, + AppLocalizations l10n, + ) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.confirmBreakingNewsTitle), + content: Text(l10n.confirmBreakingNewsMessage), + actions: [ + TextButton( + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(false); + }, + child: Text(l10n.cancelButton), + ), + TextButton( + onPressed: () { + if (!context.mounted) return; + Navigator.of(context).pop(true); + }, + child: Text(l10n.confirmPublishButton), + ), + ], + ), + ); + } } diff --git a/lib/content_management/view/edit_headline_page.dart b/lib/content_management/view/edit_headline_page.dart index 06aee388..611ca305 100644 --- a/lib/content_management/view/edit_headline_page.dart +++ b/lib/content_management/view/edit_headline_page.dart @@ -147,6 +147,7 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { _excerptController.text = state.excerpt; _urlController.text = state.url; _imageUrlController.text = state.imageUrl; + // No need to update a controller for `isBreaking` as it's a Switch. } }, builder: (context, state) { @@ -221,6 +222,25 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { .add(EditHeadlineImageUrlChanged(value)), ), const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: Text( + l10n.isBreakingNewsLabel, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Switch( + value: state.isBreaking, + onChanged: (value) => context + .read() + .add(EditHeadlineIsBreakingChanged(value)), + ), + ], + ), + Text(l10n.isBreakingNewsDescriptionEdit), + const SizedBox(height: AppSpacing.lg), + // Existing SearchableSelectionInput widgets SearchableSelectionInput( label: l10n.sourceName, selectedItems: state.source != null diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index c9b90eef..f4ba6b18 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -221,10 +221,32 @@ class _HeadlinesDataSource extends DataTableSource { }, cells: [ DataCell( - Text( - headline.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, + Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + // Add padding to the text to make space for the icon only when + // the headline is breaking news. This ensures all titles align + // vertically regardless of the icon's presence. + Padding( + padding: EdgeInsetsDirectional.only( + start: headline.isBreaking + ? AppSpacing.xl + AppSpacing.xs + : 0, + ), + child: Text( + headline.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + // Conditionally display the icon at the start of the cell. + if (headline.isBreaking) + Icon( + Icons.flash_on, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], ), ), if (!isMobile) // Conditionally show Source Name diff --git a/lib/content_management/view/topics_page.dart b/lib/content_management/view/topics_page.dart index e7152456..fc12d199 100644 --- a/lib/content_management/view/topics_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -219,7 +219,6 @@ class _TopicsDataSource extends DataTableSource { ), DataCell( Text( - // TODO(fulleni): Make date format configurable by admin. DateFormat('dd-MM-yyyy').format(topic.updatedAt.toLocal()), ), ), diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart index a1b1e94b..036f6ab7 100644 --- a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart @@ -9,6 +9,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/filter_dialog.dart' show FilterDialog; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/constants.dart'; @@ -58,6 +59,9 @@ class FilterDialogBloc extends Bloc { on( _onFilterDialogHeadlinesTopicIdsChanged, ); + on( + _onFilterDialogBreakingNewsChanged, + ); on( _onFilterDialogHeadlinesCountryIdsChanged, ); @@ -94,6 +98,7 @@ class FilterDialogBloc extends Bloc { selectedSourceIds: headlinesState.selectedSourceIds, selectedTopicIds: headlinesState.selectedTopicIds, selectedCountryIds: headlinesState.selectedCountryIds, + isBreaking: headlinesState.isBreaking, ), ); } @@ -219,6 +224,16 @@ class FilterDialogBloc extends Bloc { emit(state.copyWith(selectedCountryIds: event.countryIds)); } + /// Updates the temporary breaking news filter for headlines. + void _onFilterDialogBreakingNewsChanged( + FilterDialogBreakingNewsChanged event, + Emitter emit, + ) { + emit( + state.copyWith(isBreaking: event.isBreaking), + ); + } + /// Updates the temporary selected source types for sources. void _onFilterDialogSourceTypesChanged( FilterDialogSourceTypesChanged event, diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart index f6ecb976..eac566e5 100644 --- a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart @@ -86,6 +86,16 @@ final class FilterDialogHeadlinesCountryIdsChanged extends FilterDialogEvent { List get props => [countryIds]; } +/// Event to update the temporary breaking news filter for headlines. +final class FilterDialogBreakingNewsChanged extends FilterDialogEvent { + const FilterDialogBreakingNewsChanged(this.isBreaking); + + final BreakingNewsFilterStatus isBreaking; + + @override + List get props => [isBreaking]; +} + /// Event to update the temporary selected source types for sources. final class FilterDialogSourceTypesChanged extends FilterDialogEvent { const FilterDialogSourceTypesChanged(this.sourceTypes); diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart index da727489..3373615e 100644 --- a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart @@ -30,6 +30,7 @@ final class FilterDialogState extends Equatable { this.selectedSourceIds = const [], this.selectedTopicIds = const [], this.selectedCountryIds = const [], + this.isBreaking = BreakingNewsFilterStatus.all, this.selectedSourceTypes = const [], this.selectedLanguageCodes = const [], this.selectedHeadquartersCountryIds = const [], @@ -66,6 +67,10 @@ final class FilterDialogState extends Equatable { /// The list of country IDs to be included in the filter for headlines. final List selectedCountryIds; + /// The breaking news status to filter by for headlines. + /// `null` = all, `true` = breaking only, `false` = non-breaking only. + final BreakingNewsFilterStatus isBreaking; + /// The list of source types to be included in the filter for sources. final List selectedSourceTypes; @@ -98,6 +103,7 @@ final class FilterDialogState extends Equatable { List? selectedSourceIds, List? selectedTopicIds, List? selectedCountryIds, + BreakingNewsFilterStatus? isBreaking, List? selectedSourceTypes, List? selectedLanguageCodes, List? selectedHeadquartersCountryIds, @@ -116,6 +122,7 @@ final class FilterDialogState extends Equatable { selectedSourceIds: selectedSourceIds ?? this.selectedSourceIds, selectedTopicIds: selectedTopicIds ?? this.selectedTopicIds, selectedCountryIds: selectedCountryIds ?? this.selectedCountryIds, + isBreaking: isBreaking ?? this.isBreaking, selectedSourceTypes: selectedSourceTypes ?? this.selectedSourceTypes, selectedLanguageCodes: selectedLanguageCodes ?? this.selectedLanguageCodes, @@ -139,6 +146,7 @@ final class FilterDialogState extends Equatable { selectedSourceIds, selectedTopicIds, selectedCountryIds, + isBreaking, selectedSourceTypes, selectedLanguageCodes, selectedHeadquartersCountryIds, diff --git a/lib/content_management/widgets/filter_dialog/filter_dialog.dart b/lib/content_management/widgets/filter_dialog/filter_dialog.dart index d2a2de27..d340802b 100644 --- a/lib/content_management/widgets/filter_dialog/filter_dialog.dart +++ b/lib/content_management/widgets/filter_dialog/filter_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; @@ -103,7 +104,11 @@ class _FilterDialogState extends State { // so we can directly use BlocBuilder here. return BlocBuilder( builder: (context, filterDialogState) { - _searchController.text = filterDialogState.searchQuery; + // Synchronize the controller with the state, but only if they differ. + // This prevents the cursor from jumping and text from being re-selected. + if (_searchController.text != filterDialogState.searchQuery) { + _searchController.text = filterDialogState.searchQuery; + } return Scaffold( appBar: AppBar( title: Text(_getDialogTitle(l10n)), @@ -130,6 +135,7 @@ class _FilterDialogState extends State { selectedSourceIds: [], selectedTopicIds: [], selectedCountryIds: [], + isBreaking: BreakingNewsFilterStatus.all, selectedSourceTypes: [], selectedLanguageCodes: [], selectedHeadquartersCountryIds: [], @@ -175,7 +181,6 @@ class _FilterDialogState extends State { ), const SizedBox(height: AppSpacing.sm), _buildStatusFilterChips(l10n, theme, filterDialogState), - const SizedBox(height: AppSpacing.lg), _buildAdditionalFilters(l10n, filterDialogState), ], ), @@ -250,6 +255,38 @@ class _FilterDialogState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: AppSpacing.lg), + Text( + l10n.breakingNewsFilterTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + children: [ + ...BreakingNewsFilterStatus.values.map((status) { + return ChoiceChip( + label: Text(_getBreakingNewsStatusL10n(status, l10n)), + selected: filterDialogState.isBreaking == status, + onSelected: (isSelected) { + if (isSelected) { + context.read().add( + FilterDialogBreakingNewsChanged(status), + ); + } + }, + selectedColor: Theme.of( + context, + ).colorScheme.primaryContainer, + labelStyle: TextStyle( + color: filterDialogState.isBreaking == status + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface, + ), + ); + }), + ], + ), const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.sources, @@ -493,6 +530,21 @@ class _FilterDialogState extends State { } } + /// Returns the localized string for a given [BreakingNewsFilterStatus]. + String _getBreakingNewsStatusL10n( + BreakingNewsFilterStatus status, + AppLocalizations l10n, + ) { + switch (status) { + case BreakingNewsFilterStatus.all: + return l10n.breakingNewsFilterAll; + case BreakingNewsFilterStatus.breakingOnly: + return l10n.breakingNewsFilterBreakingOnly; + case BreakingNewsFilterStatus.nonBreakingOnly: + return l10n.breakingNewsFilterNonBreakingOnly; + } + } + /// Dispatches the filter applied event to the appropriate BLoC. void _dispatchFilterApplied(FilterDialogState filterDialogState) { switch (widget.activeTab) { @@ -504,6 +556,7 @@ class _FilterDialogState extends State { selectedSourceIds: filterDialogState.selectedSourceIds, selectedTopicIds: filterDialogState.selectedTopicIds, selectedCountryIds: filterDialogState.selectedCountryIds, + isBreaking: filterDialogState.isBreaking, ), ); case ContentManagementTab.topics: diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ddae4859..e13c8776 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2671,6 +2671,222 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Premium'** String get subscriptionPremium; + + /// Title for the Saved Headline Filter Limits section + /// + /// In en, this message translates to: + /// **'Saved Headline Filter Limits'** + String get savedHeadlineFilterLimitsTitle; + + /// Description for the Saved Headline Filter Limits section + /// + /// In en, this message translates to: + /// **'Set limits on the number of saved headline filters for each user tier, including total, pinned, and notification subscriptions.'** + String get savedHeadlineFilterLimitsDescription; + + /// Title for the Saved Source Filter Limits section + /// + /// In en, this message translates to: + /// **'Saved Source Filter Limits'** + String get savedSourceFilterLimitsTitle; + + /// Description for the Saved Source Filter Limits section + /// + /// In en, this message translates to: + /// **'Set limits on the number of saved source filters for each user tier, including total and pinned.'** + String get savedSourceFilterLimitsDescription; + + /// Label for the total limit of a filter type + /// + /// In en, this message translates to: + /// **'Total Limit'** + String get totalLimitLabel; + + /// Description for the total limit of a filter type + /// + /// In en, this message translates to: + /// **'The total number of filters of this type a user can create.'** + String get totalLimitDescription; + + /// Label for the pinned limit of a filter type + /// + /// In en, this message translates to: + /// **'Pinned Limit'** + String get pinnedLimitLabel; + + /// Description for the pinned limit of a filter type + /// + /// In en, this message translates to: + /// **'The maximum number of filters of this type that can be pinned.'** + String get pinnedLimitDescription; + + /// Label for the notification subscription limit of a filter type + /// + /// In en, this message translates to: + /// **'Notification Subscription Limit'** + String get notificationSubscriptionLimitLabel; + + /// Description for the notification subscription limit of a filter type + /// + /// In en, this message translates to: + /// **'The maximum number of filters a user can subscribe to for this notification type.'** + String get notificationSubscriptionLimitDescription; + + /// Label for the 'breaking only' push notification delivery type + /// + /// In en, this message translates to: + /// **'Breaking News'** + String get pushNotificationSubscriptionDeliveryTypeBreakingOnly; + + /// Label for the 'daily digest' push notification delivery type + /// + /// In en, this message translates to: + /// **'Daily Digest'** + String get pushNotificationSubscriptionDeliveryTypeDailyDigest; + + /// Label for the 'weekly roundup' push notification delivery type + /// + /// In en, this message translates to: + /// **'Weekly Roundup'** + String get pushNotificationSubscriptionDeliveryTypeWeeklyRoundup; + + /// Label for the switch to mark a headline as breaking news + /// + /// In en, this message translates to: + /// **'Mark as Breaking News'** + String get isBreakingNewsLabel; + + /// Description for the breaking news switch on the create page + /// + /// In en, this message translates to: + /// **'Enabling this will send an immediate push notification to all subscribed users upon publication.'** + String get isBreakingNewsDescription; + + /// Description for the breaking news switch on the edit page + /// + /// In en, this message translates to: + /// **'Changing this status during an edit will NOT trigger a new push notification, as notifications are only sent on initial creation.'** + String get isBreakingNewsDescriptionEdit; + + /// Title for the confirmation dialog when publishing breaking news + /// + /// In en, this message translates to: + /// **'Confirm Breaking News Publication'** + String get confirmBreakingNewsTitle; + + /// Message for the confirmation dialog when publishing breaking news + /// + /// In en, this message translates to: + /// **'Are you sure you want to publish this as breaking news? This action will send an immediate push notification to all subscribed users.'** + String get confirmBreakingNewsMessage; + + /// Text for the button to confirm publishing breaking news + /// + /// In en, this message translates to: + /// **'Confirm & Publish'** + String get confirmPublishButton; + + /// Error message shown when a user tries to save a breaking news article as a draft. + /// + /// In en, this message translates to: + /// **'Breaking news cannot be saved as a draft. Please publish it or disable the \'Breaking News\' toggle.'** + String get cannotDraftBreakingNews; + + /// A common 'OK' button text. + /// + /// In en, this message translates to: + /// **'OK'** + String get ok; + + /// Title for the breaking news filter section in the filter dialog. + /// + /// In en, this message translates to: + /// **'Breaking News'** + String get breakingNewsFilterTitle; + + /// Label for the 'All' choice chip in the breaking news filter. + /// + /// In en, this message translates to: + /// **'All'** + String get breakingNewsFilterAll; + + /// Label for the 'Breaking Only' choice chip in the breaking news filter. + /// + /// In en, this message translates to: + /// **'Breaking Only'** + String get breakingNewsFilterBreakingOnly; + + /// Label for the 'Non-Breaking Only' choice chip in the breaking news filter. + /// + /// In en, this message translates to: + /// **'Non-Breaking Only'** + String get breakingNewsFilterNonBreakingOnly; + + /// Tab title for Notifications settings + /// + /// In en, this message translates to: + /// **'Notifications'** + String get notificationsTab; + + /// Title for the Push Notification Settings section + /// + /// In en, this message translates to: + /// **'Push Notification Settings'** + String get pushNotificationSettingsTitle; + + /// Description for the Push Notification Settings section + /// + /// In en, this message translates to: + /// **'Manage global settings for the push notification system, including the primary provider and which notification types are active.'** + String get pushNotificationSettingsDescription; + + /// Title for the Push Notification System status section + /// + /// In en, this message translates to: + /// **'Enable Notifications'** + String get pushNotificationSystemStatusTitle; + + /// Description for the Push Notification System status section + /// + /// In en, this message translates to: + /// **'A global switch to enable or disable all push notifications.'** + String get pushNotificationSystemStatusDescription; + + /// Title for the Push Notification Primary Provider section + /// + /// In en, this message translates to: + /// **'Primary Provider'** + String get pushNotificationPrimaryProviderTitle; + + /// Description for the Push Notification Primary Provider section + /// + /// In en, this message translates to: + /// **'Select the primary service provider. Ensure the chosen provider is correctly configured in your backend\'s .env file as per the documentation.'** + String get pushNotificationPrimaryProviderDescription; + + /// Title for the Push Notification Delivery Types section + /// + /// In en, this message translates to: + /// **'Delivery Types'** + String get pushNotificationDeliveryTypesTitle; + + /// Description for the Push Notification Delivery Types section + /// + /// In en, this message translates to: + /// **'Globally enable or disable specific types of push notifications.'** + String get pushNotificationDeliveryTypesDescription; + + /// Label for the Firebase push notification provider + /// + /// In en, this message translates to: + /// **'Firebase'** + String get pushNotificationProviderFirebase; + + /// Label for the OneSignal push notification provider + /// + /// In en, this message translates to: + /// **'OneSignal'** + String get pushNotificationProviderOneSignal; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 83a758c5..bb30b7ce 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -1421,4 +1421,128 @@ class AppLocalizationsAr extends AppLocalizations { @override String get subscriptionPremium => 'مميز'; + + @override + String get savedHeadlineFilterLimitsTitle => 'حدود مرشحات العناوين المحفوظة'; + + @override + String get savedHeadlineFilterLimitsDescription => + 'تعيين حدود على عدد مرشحات العناوين المحفوظة لكل فئة من فئات المستخدمين، بما في ذلك الإجمالي والمثبتة واشتراكات الإشعارات.'; + + @override + String get savedSourceFilterLimitsTitle => 'حدود مرشحات المصادر المحفوظة'; + + @override + String get savedSourceFilterLimitsDescription => + 'تعيين حدود على عدد مرشحات المصادر المحفوظة لكل فئة من فئات المستخدمين، بما في ذلك الإجمالي والمثبتة.'; + + @override + String get totalLimitLabel => 'الحد الإجمالي'; + + @override + String get totalLimitDescription => + 'العدد الإجمالي للمرشحات من هذا النوع التي يمكن للمستخدم إنشاؤها.'; + + @override + String get pinnedLimitLabel => 'الحد المثبت'; + + @override + String get pinnedLimitDescription => + 'الحد الأقصى لعدد المرشحات من هذا النوع التي يمكن تثبيتها.'; + + @override + String get notificationSubscriptionLimitLabel => 'حد اشتراك الإشعارات'; + + @override + String get notificationSubscriptionLimitDescription => + 'الحد الأقصى لعدد المرشحات التي يمكن للمستخدم الاشتراك فيها لهذا النوع من الإشعارات.'; + + @override + String get pushNotificationSubscriptionDeliveryTypeBreakingOnly => + 'الأخبار العاجلة'; + + @override + String get pushNotificationSubscriptionDeliveryTypeDailyDigest => + 'الملخص اليومي'; + + @override + String get pushNotificationSubscriptionDeliveryTypeWeeklyRoundup => + 'حصاد الأسبوع'; + + @override + String get isBreakingNewsLabel => 'وضع علامة \'خبر عاجل\''; + + @override + String get isBreakingNewsDescription => + 'سيؤدي تمكين هذا إلى إرسال إشعار فوري لجميع المستخدمين المشتركين عند النشر.'; + + @override + String get isBreakingNewsDescriptionEdit => + 'تغيير هذه الحالة أثناء التعديل لن يؤدي إلى إرسال إشعار فوري جديد، حيث يتم إرسال الإشعارات فقط عند الإنشاء الأولي.'; + + @override + String get confirmBreakingNewsTitle => 'تأكيد نشر خبر عاجل'; + + @override + String get confirmBreakingNewsMessage => + 'هل أنت متأكد أنك تريد نشر هذا كخبر عاجل؟ سيؤدي هذا الإجراء إلى إرسال إشعار فوري لجميع المستخدمين المشتركين.'; + + @override + String get confirmPublishButton => 'تأكيد ونشر'; + + @override + String get cannotDraftBreakingNews => + 'لا يمكن حفظ الأخبار العاجلة كمسودة. يرجى نشرها أو تعطيل مفتاح \'خبر عاجل\'.'; + + @override + String get ok => 'موافق'; + + @override + String get breakingNewsFilterTitle => 'تصفية الأخبار العاجلة'; + + @override + String get breakingNewsFilterAll => 'الكل'; + + @override + String get breakingNewsFilterBreakingOnly => 'العاجلة فقط'; + + @override + String get breakingNewsFilterNonBreakingOnly => 'غير العاجلة فقط'; + + @override + String get notificationsTab => 'الإشعارات'; + + @override + String get pushNotificationSettingsTitle => 'إعدادات الإشعارات الفورية'; + + @override + String get pushNotificationSettingsDescription => + 'إدارة الإعدادات العامة لنظام الإشعارات الفورية، بما في ذلك المزود الأساسي وأنواع الإشعارات النشطة.'; + + @override + String get pushNotificationSystemStatusTitle => 'تفعيل الإشعارات'; + + @override + String get pushNotificationSystemStatusDescription => + 'مفتاح عام لتمكين أو تعطيل جميع الإشعارات الفورية.'; + + @override + String get pushNotificationPrimaryProviderTitle => 'المزود الأساسي'; + + @override + String get pushNotificationPrimaryProviderDescription => + 'اختر مزود الخدمة الأساسي. تأكد من أن المزود المختار قد تم إعداده بشكل صحيح في ملف .env الخاص بالواجهة الخلفية.'; + + @override + String get pushNotificationDeliveryTypesTitle => 'أنواع التسليم'; + + @override + String get pushNotificationDeliveryTypesDescription => + 'تمكين أو تعطيل أنواع معينة من الإشعارات الفورية.'; + + @override + String get pushNotificationProviderFirebase => 'Firebase'; + + @override + String get pushNotificationProviderOneSignal => 'OneSignal'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2aceca0c..b4066573 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1423,4 +1423,129 @@ class AppLocalizationsEn extends AppLocalizations { @override String get subscriptionPremium => 'Premium'; + + @override + String get savedHeadlineFilterLimitsTitle => 'Saved Headline Filter Limits'; + + @override + String get savedHeadlineFilterLimitsDescription => + 'Set limits on the number of saved headline filters for each user tier, including total, pinned, and notification subscriptions.'; + + @override + String get savedSourceFilterLimitsTitle => 'Saved Source Filter Limits'; + + @override + String get savedSourceFilterLimitsDescription => + 'Set limits on the number of saved source filters for each user tier, including total and pinned.'; + + @override + String get totalLimitLabel => 'Total Limit'; + + @override + String get totalLimitDescription => + 'The total number of filters of this type a user can create.'; + + @override + String get pinnedLimitLabel => 'Pinned Limit'; + + @override + String get pinnedLimitDescription => + 'The maximum number of filters of this type that can be pinned.'; + + @override + String get notificationSubscriptionLimitLabel => + 'Notification Subscription Limit'; + + @override + String get notificationSubscriptionLimitDescription => + 'The maximum number of filters a user can subscribe to for this notification type.'; + + @override + String get pushNotificationSubscriptionDeliveryTypeBreakingOnly => + 'Breaking News'; + + @override + String get pushNotificationSubscriptionDeliveryTypeDailyDigest => + 'Daily Digest'; + + @override + String get pushNotificationSubscriptionDeliveryTypeWeeklyRoundup => + 'Weekly Roundup'; + + @override + String get isBreakingNewsLabel => 'Mark as Breaking News'; + + @override + String get isBreakingNewsDescription => + 'Enabling this will send an immediate push notification to all subscribed users upon publication.'; + + @override + String get isBreakingNewsDescriptionEdit => + 'Changing this status during an edit will NOT trigger a new push notification, as notifications are only sent on initial creation.'; + + @override + String get confirmBreakingNewsTitle => 'Confirm Breaking News Publication'; + + @override + String get confirmBreakingNewsMessage => + 'Are you sure you want to publish this as breaking news? This action will send an immediate push notification to all subscribed users.'; + + @override + String get confirmPublishButton => 'Confirm & Publish'; + + @override + String get cannotDraftBreakingNews => + 'Breaking news cannot be saved as a draft. Please publish it or disable the \'Breaking News\' toggle.'; + + @override + String get ok => 'OK'; + + @override + String get breakingNewsFilterTitle => 'Breaking News'; + + @override + String get breakingNewsFilterAll => 'All'; + + @override + String get breakingNewsFilterBreakingOnly => 'Breaking Only'; + + @override + String get breakingNewsFilterNonBreakingOnly => 'Non-Breaking Only'; + + @override + String get notificationsTab => 'Notifications'; + + @override + String get pushNotificationSettingsTitle => 'Push Notification Settings'; + + @override + String get pushNotificationSettingsDescription => + 'Manage global settings for the push notification system, including the primary provider and which notification types are active.'; + + @override + String get pushNotificationSystemStatusTitle => 'Enable Notifications'; + + @override + String get pushNotificationSystemStatusDescription => + 'A global switch to enable or disable all push notifications.'; + + @override + String get pushNotificationPrimaryProviderTitle => 'Primary Provider'; + + @override + String get pushNotificationPrimaryProviderDescription => + 'Select the primary service provider. Ensure the chosen provider is correctly configured in your backend\'s .env file as per the documentation.'; + + @override + String get pushNotificationDeliveryTypesTitle => 'Delivery Types'; + + @override + String get pushNotificationDeliveryTypesDescription => + 'Globally enable or disable specific types of push notifications.'; + + @override + String get pushNotificationProviderFirebase => 'Firebase'; + + @override + String get pushNotificationProviderOneSignal => 'OneSignal'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c43b11b7..be004178 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1802,4 +1802,150 @@ "@subscriptionPremium": { "description": "حالة الاشتراك لمستخدم مميز" } +, + "savedHeadlineFilterLimitsTitle": "حدود مرشحات العناوين المحفوظة", + "@savedHeadlineFilterLimitsTitle": { + "description": "عنوان قسم حدود مرشحات العناوين المحفوظة" + }, + "savedHeadlineFilterLimitsDescription": "تعيين حدود على عدد مرشحات العناوين المحفوظة لكل فئة من فئات المستخدمين، بما في ذلك الإجمالي والمثبتة واشتراكات الإشعارات.", + "@savedHeadlineFilterLimitsDescription": { + "description": "وصف قسم حدود مرشحات العناوين المحفوظة" + }, + "savedSourceFilterLimitsTitle": "حدود مرشحات المصادر المحفوظة", + "@savedSourceFilterLimitsTitle": { + "description": "عنوان قسم حدود مرشحات المصادر المحفوظة" + }, + "savedSourceFilterLimitsDescription": "تعيين حدود على عدد مرشحات المصادر المحفوظة لكل فئة من فئات المستخدمين، بما في ذلك الإجمالي والمثبتة.", + "@savedSourceFilterLimitsDescription": { + "description": "وصف قسم حدود مرشحات المصادر المحفوظة" + }, + "totalLimitLabel": "الحد الإجمالي", + "@totalLimitLabel": { + "description": "تسمية للحد الإجمالي لنوع مرشح" + }, + "totalLimitDescription": "العدد الإجمالي للمرشحات من هذا النوع التي يمكن للمستخدم إنشاؤها.", + "@totalLimitDescription": { + "description": "وصف للحد الإجمالي لنوع مرشح" + }, + "pinnedLimitLabel": "الحد المثبت", + "@pinnedLimitLabel": { + "description": "تسمية للحد المثبت لنوع مرشح" + }, + "pinnedLimitDescription": "الحد الأقصى لعدد المرشحات من هذا النوع التي يمكن تثبيتها.", + "@pinnedLimitDescription": { + "description": "وصف للحد المثبت لنوع مرشح" + }, + "notificationSubscriptionLimitLabel": "حد اشتراك الإشعارات", + "@notificationSubscriptionLimitLabel": { + "description": "تسمية لحد اشتراك الإشعارات لنوع مرشح" + }, + "notificationSubscriptionLimitDescription": "الحد الأقصى لعدد المرشحات التي يمكن للمستخدم الاشتراك فيها لهذا النوع من الإشعارات.", + "@notificationSubscriptionLimitDescription": { + "description": "وصف لحد اشتراك الإشعارات لنوع مرشح" + }, + "pushNotificationSubscriptionDeliveryTypeBreakingOnly": "الأخبار العاجلة", + "@pushNotificationSubscriptionDeliveryTypeBreakingOnly": { + "description": "تسمية لنوع توصيل إشعارات 'الأخبار العاجلة فقط'" + }, + "pushNotificationSubscriptionDeliveryTypeDailyDigest": "الملخص اليومي", + "@pushNotificationSubscriptionDeliveryTypeDailyDigest": { + "description": "تسمية لنوع توصيل إشعارات 'الملخص اليومي'" + }, + "pushNotificationSubscriptionDeliveryTypeWeeklyRoundup": "حصاد الأسبوع", + "@pushNotificationSubscriptionDeliveryTypeWeeklyRoundup": { + "description": "تسمية لنوع توصيل إشعارات 'حصاد الأسبوع'" + } +, + "isBreakingNewsLabel": "وضع علامة 'خبر عاجل'", + "@isBreakingNewsLabel": { + "description": "تسمية مفتاح وضع علامة 'خبر عاجل' على العنوان" + }, + "isBreakingNewsDescription": "سيؤدي تمكين هذا إلى إرسال إشعار فوري لجميع المستخدمين المشتركين عند النشر.", + "@isBreakingNewsDescription": { + "description": "وصف مفتاح 'خبر عاجل' في صفحة الإنشاء" + }, + "isBreakingNewsDescriptionEdit": "تغيير هذه الحالة أثناء التعديل لن يؤدي إلى إرسال إشعار فوري جديد، حيث يتم إرسال الإشعارات فقط عند الإنشاء الأولي.", + "@isBreakingNewsDescriptionEdit": { + "description": "وصف مفتاح 'خبر عاجل' في صفحة التعديل" + }, + "confirmBreakingNewsTitle": "تأكيد نشر خبر عاجل", + "@confirmBreakingNewsTitle": { + "description": "عنوان مربع حوار التأكيد عند نشر خبر عاجل" + }, + "confirmBreakingNewsMessage": "هل أنت متأكد أنك تريد نشر هذا كخبر عاجل؟ سيؤدي هذا الإجراء إلى إرسال إشعار فوري لجميع المستخدمين المشتركين.", + "@confirmBreakingNewsMessage": { + "description": "رسالة مربع حوار التأكيد عند نشر خبر عاجل" + }, + "confirmPublishButton": "تأكيد ونشر", + "@confirmPublishButton": { + "description": "نص زر التأكيد والنشر لخبر عاجل" + }, + "cannotDraftBreakingNews": "لا يمكن حفظ الأخبار العاجلة كمسودة. يرجى نشرها أو تعطيل مفتاح 'خبر عاجل'.", + "@cannotDraftBreakingNews": { + "description": "رسالة خطأ تظهر عندما يحاول المستخدم حفظ مقال إخباري عاجل كمسودة." + }, + "ok": "موافق", + "@ok": { + "description": "نص زر 'موافق' الشائع." + }, + "breakingNewsFilterTitle": "تصفية الأخبار العاجلة", + "@breakingNewsFilterTitle": { + "description": "عنوان قسم تصفية الأخبار العاجلة في مربع حوار التصفية." + }, + "breakingNewsFilterAll": "الكل", + "@breakingNewsFilterAll": { + "description": "تسمية شريحة الاختيار 'الكل' في مرشح الأخبار العاجلة." + }, + "breakingNewsFilterBreakingOnly": "العاجلة فقط", + "@breakingNewsFilterBreakingOnly": { + "description": "تسمية شريحة الاختيار 'العاجلة فقط' في مرشح الأخبار العاجلة." + }, + "breakingNewsFilterNonBreakingOnly": "غير العاجلة فقط", + "@breakingNewsFilterNonBreakingOnly": { + "description": "تسمية شريحة الاختيار 'غير العاجلة فقط' في مرشح الأخبار العاجلة." + }, + "notificationsTab": "الإشعارات", + "@notificationsTab": { + "description": "عنوان تبويب إعدادات الإشعارات" + }, + "pushNotificationSettingsTitle": "إعدادات الإشعارات الفورية", + "@pushNotificationSettingsTitle": { + "description": "عنوان قسم إعدادات الإشعارات الفورية" + }, + "pushNotificationSettingsDescription": "إدارة الإعدادات العامة لنظام الإشعارات الفورية، بما في ذلك المزود الأساسي وأنواع الإشعارات النشطة.", + "@pushNotificationSettingsDescription": { + "description": "وصف قسم إعدادات الإشعارات الفورية" + }, + "pushNotificationSystemStatusTitle": "تفعيل الإشعارات", + "@pushNotificationSystemStatusTitle": { + "description": "عنوان قسم حالة نظام الإشعارات الفورية" + }, + "pushNotificationSystemStatusDescription": "مفتاح عام لتمكين أو تعطيل جميع الإشعارات الفورية.", + "@pushNotificationSystemStatusDescription": { + "description": "وصف قسم حالة نظام الإشعارات الفورية" + }, + "pushNotificationPrimaryProviderTitle": "المزود الأساسي", + "@pushNotificationPrimaryProviderTitle": { + "description": "عنوان قسم المزود الأساسي للإشعارات الفورية" + }, + "pushNotificationPrimaryProviderDescription": "اختر مزود الخدمة الأساسي. تأكد من أن المزود المختار قد تم إعداده بشكل صحيح في ملف .env الخاص بالواجهة الخلفية.", + "@pushNotificationPrimaryProviderDescription": { + "description": "وصف قسم المزود الأساسي للإشعارات الفورية" + }, + "pushNotificationDeliveryTypesTitle": "أنواع التسليم", + "@pushNotificationDeliveryTypesTitle": { + "description": "عنوان قسم أنواع تسليم الإشعارات الفورية" + }, + "pushNotificationDeliveryTypesDescription": "تمكين أو تعطيل أنواع معينة من الإشعارات الفورية.", + "@pushNotificationDeliveryTypesDescription": { + "description": "وصف قسم أنواع تسليم الإشعارات الفورية" + }, + "pushNotificationProviderFirebase": "Firebase", + "@pushNotificationProviderFirebase": { + "description": "تسمية مزود الإشعارات الفورية Firebase" + }, + "pushNotificationProviderOneSignal": "OneSignal", + "@pushNotificationProviderOneSignal": { + "description": "تسمية مزود الإشعارات الفورية OneSignal" + } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 31b4f90a..643aef4b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1797,5 +1797,149 @@ "subscriptionPremium": "Premium", "@subscriptionPremium": { "description": "Subscription status for a premium user" + }, + "savedHeadlineFilterLimitsTitle": "Saved Headline Filter Limits", + "@savedHeadlineFilterLimitsTitle": { + "description": "Title for the Saved Headline Filter Limits section" + }, + "savedHeadlineFilterLimitsDescription": "Set limits on the number of saved headline filters for each user tier, including total, pinned, and notification subscriptions.", + "@savedHeadlineFilterLimitsDescription": { + "description": "Description for the Saved Headline Filter Limits section" + }, + "savedSourceFilterLimitsTitle": "Saved Source Filter Limits", + "@savedSourceFilterLimitsTitle": { + "description": "Title for the Saved Source Filter Limits section" + }, + "savedSourceFilterLimitsDescription": "Set limits on the number of saved source filters for each user tier, including total and pinned.", + "@savedSourceFilterLimitsDescription": { + "description": "Description for the Saved Source Filter Limits section" + }, + "totalLimitLabel": "Total Limit", + "@totalLimitLabel": { + "description": "Label for the total limit of a filter type" + }, + "totalLimitDescription": "The total number of filters of this type a user can create.", + "@totalLimitDescription": { + "description": "Description for the total limit of a filter type" + }, + "pinnedLimitLabel": "Pinned Limit", + "@pinnedLimitLabel": { + "description": "Label for the pinned limit of a filter type" + }, + "pinnedLimitDescription": "The maximum number of filters of this type that can be pinned.", + "@pinnedLimitDescription": { + "description": "Description for the pinned limit of a filter type" + }, + "notificationSubscriptionLimitLabel": "Notification Subscription Limit", + "@notificationSubscriptionLimitLabel": { + "description": "Label for the notification subscription limit of a filter type" + }, + "notificationSubscriptionLimitDescription": "The maximum number of filters a user can subscribe to for this notification type.", + "@notificationSubscriptionLimitDescription": { + "description": "Description for the notification subscription limit of a filter type" + }, + "pushNotificationSubscriptionDeliveryTypeBreakingOnly": "Breaking News", + "@pushNotificationSubscriptionDeliveryTypeBreakingOnly": { + "description": "Label for the 'breaking only' push notification delivery type" + }, + "pushNotificationSubscriptionDeliveryTypeDailyDigest": "Daily Digest", + "@pushNotificationSubscriptionDeliveryTypeDailyDigest": { + "description": "Label for the 'daily digest' push notification delivery type" + }, + "pushNotificationSubscriptionDeliveryTypeWeeklyRoundup": "Weekly Roundup", + "@pushNotificationSubscriptionDeliveryTypeWeeklyRoundup": { + "description": "Label for the 'weekly roundup' push notification delivery type" + }, + "isBreakingNewsLabel": "Mark as Breaking News", + "@isBreakingNewsLabel": { + "description": "Label for the switch to mark a headline as breaking news" + }, + "isBreakingNewsDescription": "Enabling this will send an immediate push notification to all subscribed users upon publication.", + "@isBreakingNewsDescription": { + "description": "Description for the breaking news switch on the create page" + }, + "isBreakingNewsDescriptionEdit": "Changing this status during an edit will NOT trigger a new push notification, as notifications are only sent on initial creation.", + "@isBreakingNewsDescriptionEdit": { + "description": "Description for the breaking news switch on the edit page" + }, + "confirmBreakingNewsTitle": "Confirm Breaking News Publication", + "@confirmBreakingNewsTitle": { + "description": "Title for the confirmation dialog when publishing breaking news" + }, + "confirmBreakingNewsMessage": "Are you sure you want to publish this as breaking news? This action will send an immediate push notification to all subscribed users.", + "@confirmBreakingNewsMessage": { + "description": "Message for the confirmation dialog when publishing breaking news" + }, + "confirmPublishButton": "Confirm & Publish", + "@confirmPublishButton": { + "description": "Text for the button to confirm publishing breaking news" + }, + "cannotDraftBreakingNews": "Breaking news cannot be saved as a draft. Please publish it or disable the 'Breaking News' toggle.", + "@cannotDraftBreakingNews": { + "description": "Error message shown when a user tries to save a breaking news article as a draft." + }, + "ok": "OK", + "@ok": { + "description": "A common 'OK' button text." + }, + "breakingNewsFilterTitle": "Breaking News", + "@breakingNewsFilterTitle": { + "description": "Title for the breaking news filter section in the filter dialog." + }, + "breakingNewsFilterAll": "All", + "@breakingNewsFilterAll": { + "description": "Label for the 'All' choice chip in the breaking news filter." + }, + "breakingNewsFilterBreakingOnly": "Breaking Only", + "@breakingNewsFilterBreakingOnly": { + "description": "Label for the 'Breaking Only' choice chip in the breaking news filter." + }, + "breakingNewsFilterNonBreakingOnly": "Non-Breaking Only", + "@breakingNewsFilterNonBreakingOnly": { + "description": "Label for the 'Non-Breaking Only' choice chip in the breaking news filter." + }, + "notificationsTab": "Notifications", + "@notificationsTab": { + "description": "Tab title for Notifications settings" + }, + "pushNotificationSettingsTitle": "Push Notification Settings", + "@pushNotificationSettingsTitle": { + "description": "Title for the Push Notification Settings section" + }, + "pushNotificationSettingsDescription": "Manage global settings for the push notification system, including the primary provider and which notification types are active.", + "@pushNotificationSettingsDescription": { + "description": "Description for the Push Notification Settings section" + }, + "pushNotificationSystemStatusTitle": "Enable Notifications", + "@pushNotificationSystemStatusTitle": { + "description": "Title for the Push Notification System status section" + }, + "pushNotificationSystemStatusDescription": "A global switch to enable or disable all push notifications.", + "@pushNotificationSystemStatusDescription": { + "description": "Description for the Push Notification System status section" + }, + "pushNotificationPrimaryProviderTitle": "Primary Provider", + "@pushNotificationPrimaryProviderTitle": { + "description": "Title for the Push Notification Primary Provider section" + }, + "pushNotificationPrimaryProviderDescription": "Select the primary service provider. Ensure the chosen provider is correctly configured in your backend's .env file as per the documentation.", + "@pushNotificationPrimaryProviderDescription": { + "description": "Description for the Push Notification Primary Provider section" + }, + "pushNotificationDeliveryTypesTitle": "Delivery Types", + "@pushNotificationDeliveryTypesTitle": { + "description": "Title for the Push Notification Delivery Types section" + }, + "pushNotificationDeliveryTypesDescription": "Globally enable or disable specific types of push notifications.", + "@pushNotificationDeliveryTypesDescription": { + "description": "Description for the Push Notification Delivery Types section" + }, + "pushNotificationProviderFirebase": "Firebase", + "@pushNotificationProviderFirebase": { + "description": "Label for the Firebase push notification provider" + }, + "pushNotificationProviderOneSignal": "OneSignal", + "@pushNotificationProviderOneSignal": { + "description": "Label for the OneSignal push notification provider" } } \ No newline at end of file diff --git a/lib/shared/extensions/extensions.dart b/lib/shared/extensions/extensions.dart index 9987aa14..bac9865f 100644 --- a/lib/shared/extensions/extensions.dart +++ b/lib/shared/extensions/extensions.dart @@ -6,5 +6,7 @@ export 'content_status_l10n.dart'; export 'dashboard_user_role_l10n.dart'; export 'feed_decorator_type_l10n.dart'; export 'in_article_ad_slot_type_l10n.dart'; +export 'push_notification_provider_l10n.dart'; +export 'push_notification_subscription_delivery_type_l10n.dart'; export 'source_type_l10n.dart'; export 'string_truncate.dart'; diff --git a/lib/shared/extensions/push_notification_provider_l10n.dart b/lib/shared/extensions/push_notification_provider_l10n.dart new file mode 100644 index 00000000..a5203c49 --- /dev/null +++ b/lib/shared/extensions/push_notification_provider_l10n.dart @@ -0,0 +1,18 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// Extension to localize the [PushNotificationProvider] enum. +extension PushNotificationProviderL10n on PushNotificationProvider { + /// Returns the localized string representation of the + /// [PushNotificationProvider]. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case PushNotificationProvider.firebase: + return l10n.pushNotificationProviderFirebase; + case PushNotificationProvider.oneSignal: + return l10n.pushNotificationProviderOneSignal; + } + } +} diff --git a/lib/shared/extensions/push_notification_subscription_delivery_type_l10n.dart b/lib/shared/extensions/push_notification_subscription_delivery_type_l10n.dart new file mode 100644 index 00000000..fbab6840 --- /dev/null +++ b/lib/shared/extensions/push_notification_subscription_delivery_type_l10n.dart @@ -0,0 +1,21 @@ +import 'package:core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// Provides a localized string representation for +/// [PushNotificationSubscriptionDeliveryType]. +extension PushNotificationSubscriptionDeliveryTypeL10n + on PushNotificationSubscriptionDeliveryType { + /// Returns the localized string for the enum value. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case PushNotificationSubscriptionDeliveryType.breakingOnly: + return l10n.pushNotificationSubscriptionDeliveryTypeBreakingOnly; + case PushNotificationSubscriptionDeliveryType.dailyDigest: + return l10n.pushNotificationSubscriptionDeliveryTypeDailyDigest; + case PushNotificationSubscriptionDeliveryType.weeklyRoundup: + return l10n.pushNotificationSubscriptionDeliveryTypeWeeklyRoundup; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index fa8c6a9f..e0782b92 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,8 +89,8 @@ packages: dependency: "direct main" description: path: "." - ref: e7c808c9d459233196e2eac3137a9c87d3976af3 - resolved-ref: e7c808c9d459233196e2eac3137a9c87d3976af3 + ref: "064c4387b3f7df835565c41c918dc2d80dd2f49a" + resolved-ref: "064c4387b3f7df835565c41c918dc2d80dd2f49a" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" @@ -293,11 +293,11 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.1" - resolved-ref: "22a1531a279769ec472f698e9c727cd2c29a81b9" + ref: "v1.1.0" + resolved-ref: e3540bcd27de93f96f4bce79cc20ff55dfe3b2bf url: "https://github.com/flutter-news-app-full-source-code/http-client.git" source: git - version: "1.0.1" + version: "1.1.0" http_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7905a815..222f4faa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,8 +91,11 @@ flutter: generate: true dependency_overrides: - # TODO: Remove this override before upcoming released. core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: e7c808c9d459233196e2eac3137a9c87d3976af3 \ No newline at end of file + ref: 064c4387b3f7df835565c41c918dc2d80dd2f49a + http_client: + git: + url: https://github.com/flutter-news-app-full-source-code/http-client.git + ref: v1.1.0 \ No newline at end of file