diff --git a/README.md b/README.md index 9d7e8c0d..caf65ffc 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ A robust, backend-driven notification system keeps users informed and brings the - **Integrated Notification Center:** Includes a full-featured in-app notification center where users can view their history. Foreground notifications are handled gracefully, appearing as an unread indicator that leads the user to this central hub, avoiding intrusive system alerts during active use. > **Your Advantage:** You get a highly flexible and scalable notification system that avoids vendor lock-in and is ready to re-engage users from day one. +--- + +### 💬 Community & Feedback Systems +A complete suite of tools to build a vibrant user community and gather valuable feedback directly within the app. +- **Configurable Headline Engagement:** Enable immediate user interaction directly on each headline within the feed. The entire engagement system is controlled via remote configuration, allowing you to dynamically adjust the depth of user interaction—from simple reactions to full comment threads—without an app update. +- **Intelligent Review Funnel:** A sophisticated, multi-layered system that strategically prompts users for an app review. Its behavior is entirely driven by remote configuration, including cooldown periods and positive interaction thresholds. It first gauges user sentiment with a private, in-app prompt: positive responses trigger the native OS review dialog, while negative responses open a private feedback form, ensuring you only ask happy users for public reviews and capture valuable insights from others. +- **Moderated Content Reporting:** Empower your community to maintain content quality with a built-in reporting system. Users can easily report headlines, sources, or individual comments through a guided process. All reports are submitted to the backend and are designed to be managed and actioned from the companion web dashboard. +> **Your Advantage:** Deploy a full-featured community and feedback system from day one. Skip the complexity of building engagement UI, state management for reactions, and the nuanced logic of a best-practice app review funnel. +
diff --git a/analysis_options.yaml b/analysis_options.yaml index 6659894d..e1e83e47 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,7 @@ analyzer: document_ignores: ignore flutter_style_todos: ignore lines_longer_than_80_chars: ignore + one_member_abstracts: ignore prefer_asserts_with_message: ignore use_build_context_synchronously: ignore use_if_null_to_convert_nulls_to_bools: ignore diff --git a/lib/account/view/followed_contents/countries/add_country_to_follow_page.dart b/lib/account/view/followed_contents/countries/add_country_to_follow_page.dart index 2ef5e4b8..f8b7a37a 100644 --- a/lib/account/view/followed_contents/countries/add_country_to_follow_page.dart +++ b/lib/account/view/followed_contents/countries/add_country_to_follow_page.dart @@ -4,11 +4,113 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_countries_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:ui_kit/ui_kit.dart'; +class _FollowButton extends StatefulWidget { + const _FollowButton({required this.country, required this.isFollowed}); + + final Country country; + final bool isFollowed; + + @override + State<_FollowButton> createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State<_FollowButton> { + bool _isLoading = false; + + Future _onFollowToggled() async { + setState(() => _isLoading = true); + + final l10n = AppLocalizations.of(context); + final appBloc = context.read(); + final userContentPreferences = appBloc.state.userContentPreferences; + + if (userContentPreferences == null) { + setState(() => _isLoading = false); + return; + } + + final updatedFollowedCountries = List.from( + userContentPreferences.followedCountries, + ); + + try { + if (widget.isFollowed) { + updatedFollowedCountries.removeWhere((c) => c.id == widget.country.id); + } else { + final limitationService = context.read(); + final status = await limitationService.checkAction( + ContentAction.followCountry, + ); + + if (status != LimitationStatus.allowed) { + if (mounted) { + showContentLimitationBottomSheet( + context: context, + status: status, + action: ContentAction.followCountry, + ); + } + return; + } + updatedFollowedCountries.add(widget.country); + } + + final updatedPreferences = userContentPreferences.copyWith( + followedCountries: updatedFollowedCountries, + ); + + appBloc.add( + AppUserContentPreferencesChanged(preferences: updatedPreferences), + ); + } on ForbiddenException catch (e) { + if (mounted) { + await showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: l10n.limitReachedTitle, + body: e.message, + buttonText: l10n.gotItButton, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + + if (_isLoading) { + return const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + return IconButton( + icon: widget.isFollowed + ? Icon(Icons.check_circle, color: colorScheme.primary) + : const Icon(Icons.add_circle_outline), + tooltip: widget.isFollowed + ? l10n.unfollowCountryTooltip(widget.country.name) + : l10n.followCountryTooltip(widget.country.name), + onPressed: _onFollowToggled, + ); + } +} + /// {@template add_country_to_follow_page} /// A page that allows users to browse and select countries to follow. /// {@endtemplate} @@ -138,76 +240,9 @@ class AddCountryToFollowPage extends StatelessWidget { ), ), title: Text(country.name, style: textTheme.titleMedium), - trailing: IconButton( - icon: isFollowed - ? Icon( - Icons.check_circle, - color: colorScheme.primary, - ) - : Icon( - Icons.add_circle_outline, - color: colorScheme.onSurfaceVariant, - ), - tooltip: isFollowed - ? l10n.unfollowCountryTooltip(country.name) - : l10n.followCountryTooltip(country.name), - onPressed: () { - // Ensure user preferences are available before - // proceeding. - if (userContentPreferences == null) return; - - // Create a mutable copy of the followed countries list. - final updatedFollowedCountries = List.from( - followedCountries, - ); - - // If the user is unfollowing, always allow it. - if (isFollowed) { - updatedFollowedCountries.removeWhere( - (c) => c.id == country.id, - ); - final updatedPreferences = userContentPreferences - .copyWith( - followedCountries: updatedFollowedCountries, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - } else { - // If the user is following, check the limit first. - final limitationService = context - .read(); - final status = limitationService.checkAction( - ContentAction.followCountry, - ); - - if (status == LimitationStatus.allowed) { - updatedFollowedCountries.add(country); - final updatedPreferences = - userContentPreferences.copyWith( - followedCountries: - updatedFollowedCountries, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - } else { - // If the limit is reached, show the bottom sheet. - showModalBottomSheet( - context: context, - builder: (_) => ContentLimitationBottomSheet( - status: status, - ), - ); - } - } - }, + trailing: _FollowButton( + country: country, + isFollowed: isFollowed, ), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.paddingMedium, diff --git a/lib/account/view/followed_contents/sources/add_source_to_follow_page.dart b/lib/account/view/followed_contents/sources/add_source_to_follow_page.dart index 85a5beb1..b8163771 100644 --- a/lib/account/view/followed_contents/sources/add_source_to_follow_page.dart +++ b/lib/account/view/followed_contents/sources/add_source_to_follow_page.dart @@ -4,11 +4,113 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_sources_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:ui_kit/ui_kit.dart'; +class _FollowButton extends StatefulWidget { + const _FollowButton({required this.source, required this.isFollowed}); + + final Source source; + final bool isFollowed; + + @override + State<_FollowButton> createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State<_FollowButton> { + bool _isLoading = false; + + Future _onFollowToggled() async { + setState(() => _isLoading = true); + + final l10n = AppLocalizations.of(context); + final appBloc = context.read(); + final userContentPreferences = appBloc.state.userContentPreferences; + + if (userContentPreferences == null) { + setState(() => _isLoading = false); + return; + } + + final updatedFollowedSources = List.from( + userContentPreferences.followedSources, + ); + + try { + if (widget.isFollowed) { + updatedFollowedSources.removeWhere((s) => s.id == widget.source.id); + } else { + final limitationService = context.read(); + final status = await limitationService.checkAction( + ContentAction.followSource, + ); + + if (status != LimitationStatus.allowed) { + if (mounted) { + showContentLimitationBottomSheet( + context: context, + status: status, + action: ContentAction.followSource, + ); + } + return; + } + updatedFollowedSources.add(widget.source); + } + + final updatedPreferences = userContentPreferences.copyWith( + followedSources: updatedFollowedSources, + ); + + appBloc.add( + AppUserContentPreferencesChanged(preferences: updatedPreferences), + ); + } on ForbiddenException catch (e) { + if (mounted) { + await showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: l10n.limitReachedTitle, + body: e.message, + buttonText: l10n.gotItButton, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + + if (_isLoading) { + return const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + return IconButton( + icon: widget.isFollowed + ? Icon(Icons.check_circle, color: colorScheme.primary) + : const Icon(Icons.add_circle_outline), + tooltip: widget.isFollowed + ? l10n.unfollowSourceTooltip(widget.source.name) + : l10n.followSourceTooltip(widget.source.name), + onPressed: _onFollowToggled, + ); + } +} + /// {@template add_source_to_follow_page} /// A page that allows users to browse and select sources to follow. /// {@endtemplate} @@ -84,72 +186,9 @@ class AddSourceToFollowPage extends StatelessWidget { ), ), title: Text(source.name), - trailing: IconButton( - icon: isFollowed - ? Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - ) - : const Icon(Icons.add_circle_outline), - tooltip: isFollowed - ? l10n.unfollowSourceTooltip(source.name) - : l10n.followSourceTooltip(source.name), - onPressed: () { - // Ensure user preferences are available before - // proceeding. - if (userContentPreferences == null) return; - - // Create a mutable copy of the followed sources list. - final updatedFollowedSources = List.from( - followedSources, - ); - - // If the user is unfollowing, always allow it. - if (isFollowed) { - updatedFollowedSources.removeWhere( - (s) => s.id == source.id, - ); - final updatedPreferences = userContentPreferences - .copyWith( - followedSources: updatedFollowedSources, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - } else { - // If the user is following, check the limit first. - final limitationService = context - .read(); - final status = limitationService.checkAction( - ContentAction.followSource, - ); - - if (status == LimitationStatus.allowed) { - updatedFollowedSources.add(source); - final updatedPreferences = - userContentPreferences.copyWith( - followedSources: updatedFollowedSources, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - } else { - // If the limit is reached, show the bottom sheet. - showModalBottomSheet( - context: context, - builder: (_) => ContentLimitationBottomSheet( - status: status, - ), - ); - } - } - }, + trailing: _FollowButton( + source: source, + isFollowed: isFollowed, ), ), ); diff --git a/lib/account/view/followed_contents/topics/add_topic_to_follow_page.dart b/lib/account/view/followed_contents/topics/add_topic_to_follow_page.dart index 9cdba041..f678ac4b 100644 --- a/lib/account/view/followed_contents/topics/add_topic_to_follow_page.dart +++ b/lib/account/view/followed_contents/topics/add_topic_to_follow_page.dart @@ -4,11 +4,113 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/available_topics_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:ui_kit/ui_kit.dart'; +class _FollowButton extends StatefulWidget { + const _FollowButton({required this.topic, required this.isFollowed}); + + final Topic topic; + final bool isFollowed; + + @override + State<_FollowButton> createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State<_FollowButton> { + bool _isLoading = false; + + Future _onFollowToggled() async { + setState(() => _isLoading = true); + + final l10n = AppLocalizations.of(context); + final appBloc = context.read(); + final userContentPreferences = appBloc.state.userContentPreferences; + + if (userContentPreferences == null) { + setState(() => _isLoading = false); + return; + } + + final updatedFollowedTopics = List.from( + userContentPreferences.followedTopics, + ); + + try { + if (widget.isFollowed) { + updatedFollowedTopics.removeWhere((t) => t.id == widget.topic.id); + } else { + final limitationService = context.read(); + final status = await limitationService.checkAction( + ContentAction.followTopic, + ); + + if (status != LimitationStatus.allowed) { + if (mounted) { + showContentLimitationBottomSheet( + context: context, + status: status, + action: ContentAction.followTopic, + ); + } + return; + } + updatedFollowedTopics.add(widget.topic); + } + + final updatedPreferences = userContentPreferences.copyWith( + followedTopics: updatedFollowedTopics, + ); + + appBloc.add( + AppUserContentPreferencesChanged(preferences: updatedPreferences), + ); + } on ForbiddenException catch (e) { + if (mounted) { + await showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: l10n.limitReachedTitle, + body: e.message, + buttonText: l10n.gotItButton, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + + if (_isLoading) { + return const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + + return IconButton( + icon: widget.isFollowed + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.add_circle_outline, color: colorScheme.onSurfaceVariant), + tooltip: widget.isFollowed + ? l10n.unfollowTopicTooltip(widget.topic.name) + : l10n.followTopicTooltip(widget.topic.name), + onPressed: _onFollowToggled, + ); + } +} + /// {@template add_topic_to_follow_page} /// A page that allows users to browse and select topics to follow. /// {@endtemplate} @@ -137,75 +239,9 @@ class AddTopicToFollowPage extends StatelessWidget { ), ), title: Text(topic.name, style: textTheme.titleMedium), - trailing: IconButton( - icon: isFollowed - ? Icon( - Icons.check_circle, - color: colorScheme.primary, - ) - : Icon( - Icons.add_circle_outline, - color: colorScheme.onSurfaceVariant, - ), - tooltip: isFollowed - ? l10n.unfollowTopicTooltip(topic.name) - : l10n.followTopicTooltip(topic.name), - onPressed: () { - // Ensure user preferences are available before - // proceeding. - if (userContentPreferences == null) return; - - // Create a mutable copy of the followed topics list. - final updatedFollowedTopics = List.from( - followedTopics, - ); - - // If the user is unfollowing, always allow it. - if (isFollowed) { - updatedFollowedTopics.removeWhere( - (t) => t.id == topic.id, - ); - final updatedPreferences = userContentPreferences - .copyWith( - followedTopics: updatedFollowedTopics, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - } else { - // If the user is following, check the limit first. - final limitationService = context - .read(); - final status = limitationService.checkAction( - ContentAction.followTopic, - ); - - if (status == LimitationStatus.allowed) { - updatedFollowedTopics.add(topic); - final updatedPreferences = - userContentPreferences.copyWith( - followedTopics: updatedFollowedTopics, - ); - - context.read().add( - AppUserContentPreferencesChanged( - preferences: updatedPreferences, - ), - ); - } else { - // If the limit is reached, show the bottom sheet. - showModalBottomSheet( - context: context, - builder: (_) => ContentLimitationBottomSheet( - status: status, - ), - ); - } - } - }, + trailing: _FollowButton( + topic: topic, + isFollowed: isFollowed, ), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.paddingMedium, diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 444a7f0c..6ad1f273 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -11,8 +11,11 @@ import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/inl import 'package:flutter_news_app_mobile_client_full_source_code/app/models/app_life_cycle_status.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/models/initialization_result.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app_initializer.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/services/feed_cache_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/extensions/extensions.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/services/app_review_service.dart'; import 'package:logging/logging.dart'; part 'app_event.dart'; @@ -44,10 +47,14 @@ class AppBloc extends Bloc { required DataRepository userContentPreferencesRepository, required InlineAdCacheService inlineAdCacheService, + required FeedCacheService feedCacheService, required Logger logger, required DataRepository userRepository, required PushNotificationService pushNotificationService, + required DataRepository reportRepository, + required ContentLimitationService contentLimitationService, required DataRepository inAppNotificationRepository, + required AppReviewService appReviewService, }) : _remoteConfigRepository = remoteConfigRepository, _appInitializer = appInitializer, _authRepository = authRepository, @@ -55,7 +62,11 @@ class AppBloc extends Bloc { _userContentPreferencesRepository = userContentPreferencesRepository, _userRepository = userRepository, _inAppNotificationRepository = inAppNotificationRepository, + _feedCacheService = feedCacheService, _pushNotificationService = pushNotificationService, + _reportRepository = reportRepository, + _contentLimitationService = contentLimitationService, + _appReviewService = appReviewService, _inlineAdCacheService = inlineAdCacheService, _logger = logger, super( @@ -93,8 +104,11 @@ class AppBloc extends Bloc { on( _onAllInAppNotificationsMarkedAsRead, ); + on(_onAppPositiveInteractionOcurred); on(_onInAppNotificationMarkedAsRead); on(_onAppNotificationTapped); + on(_onAppBookmarkToggled); + on(_onAppContentReported); // Listen to token refresh events from the push notification service. // When a token is refreshed, dispatch an event to trigger device @@ -123,7 +137,11 @@ class AppBloc extends Bloc { final DataRepository _userRepository; final DataRepository _inAppNotificationRepository; final PushNotificationService _pushNotificationService; + final DataRepository _reportRepository; + final ContentLimitationService _contentLimitationService; + final AppReviewService _appReviewService; final InlineAdCacheService _inlineAdCacheService; + final FeedCacheService _feedCacheService; /// Handles the [AppStarted] event. /// @@ -206,6 +224,10 @@ class AppBloc extends Bloc { // data to ensure a clean state for the next session. This prevents // stale data from causing issues on subsequent logins. _inlineAdCacheService.clearAllAds(); + // Also clear the feed cache to ensure the next user (or a new anonymous + // session) gets fresh data instead of seeing the previous user's feed. + _feedCacheService.clearAll(); + _logger.info('[AppBloc] Cleared inline ad and feed caches on logout.'); emit( state.copyWith( @@ -216,6 +238,17 @@ class AppBloc extends Bloc { return; } + // If the user is changing (e.g., anonymous to authenticated), clear caches + // to prevent showing stale data from the previous user session. + if (oldUser != null && oldUser.id != newUser.id) { + _inlineAdCacheService.clearAllAds(); + _feedCacheService.clearAll(); + _logger.info( + '[AppBloc] User changed from ${oldUser.id} to ${newUser.id}. ' + 'Cleared inline ad and feed caches.', + ); + } + // A user is present, so we are logging in or transitioning roles. // Show a loading screen while we handle this process. emit(state.copyWith(status: AppLifeCycleStatus.loadingUserData)); @@ -738,6 +771,24 @@ class AppBloc extends Bloc { } } + /// Handles the [AppPositiveInteractionOcurred] event. + /// + /// This handler increments the user's positive interaction count and then + /// delegates to the [AppReviewService] to check if a review prompt should + /// be shown. + Future _onAppPositiveInteractionOcurred( + AppPositiveInteractionOcurred event, + Emitter emit, + ) async { + final newCount = state.positiveInteractionCount + 1; + await _appReviewService.checkEligibilityAndTrigger( + context: event.context, + positiveInteractionCount: newCount, + ); + // The count only updated after the eligibility check is complete. + emit(state.copyWith(positiveInteractionCount: newCount)); + } + /// Handles the [AppPushNotificationTokenRefreshed] event. /// /// This event is triggered when the underlying push notification provider @@ -758,6 +809,74 @@ class AppBloc extends Bloc { await _registerDeviceForPushNotifications(state.user!.id); } + Future _onAppBookmarkToggled( + AppBookmarkToggled event, + Emitter emit, + ) async { + final userContentPreferences = state.userContentPreferences; + if (userContentPreferences == null) return; + + final currentSaved = List.from( + userContentPreferences.savedHeadlines, + ); + + if (event.isBookmarked) { + currentSaved.removeWhere((h) => h.id == event.headline.id); + } else { + final limitationStatus = await _contentLimitationService.checkAction( + ContentAction.bookmarkHeadline, + ); + + if (limitationStatus != LimitationStatus.allowed) { + emit( + state.copyWith( + limitationStatus: limitationStatus, + limitedAction: ContentAction.bookmarkHeadline, + ), + ); + emit(state.copyWith(clearLimitedAction: true)); + return; + } + currentSaved.insert(0, event.headline); + add(AppPositiveInteractionOcurred(context: event.context)); + } + + add( + AppUserContentPreferencesChanged( + preferences: userContentPreferences.copyWith( + savedHeadlines: currentSaved, + ), + ), + ); + } + + Future _onAppContentReported( + AppContentReported event, + Emitter emit, + ) async { + final limitationStatus = await _contentLimitationService.checkAction( + ContentAction.submitReport, + ); + + if (limitationStatus != LimitationStatus.allowed) { + emit( + state.copyWith( + limitationStatus: limitationStatus, + limitedAction: ContentAction.submitReport, + ), + ); + emit(state.copyWith(clearLimitedAction: true)); + return; + } + + try { + await _reportRepository.create(item: event.report); + } catch (e, s) { + _logger.severe('Failed to submit report in AppBloc', e, s); + // Optionally emit a failure state to show a snackbar. + } + } + /// A private helper method to encapsulate the logic for registering a /// device for push notifications. /// diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 131a0fb2..29c760ed 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -250,3 +250,57 @@ class AppNotificationTapped extends AppEvent { @override List get props => [notificationId]; } + +/// {@template app_positive_interaction_ocurred} +/// Dispatched when a user performs a positive interaction, such as saving an +/// article or following a topic. +/// +/// This event is used to track user engagement and trigger the app review +/// funnel. +/// {@endtemplate} +class AppPositiveInteractionOcurred extends AppEvent { + /// {@macro app_positive_interaction_ocurred} + const AppPositiveInteractionOcurred({required this.context}); + + final BuildContext context; + @override + List get props => [context]; +} + +/// {@template app_bookmark_toggled} +/// Dispatched when a user bookmarks or un-bookmarks a headline. +/// {@endtemplate} +class AppBookmarkToggled extends AppEvent { + /// {@macro app_bookmark_toggled} + const AppBookmarkToggled({ + required this.headline, + required this.isBookmarked, + required this.context, + }); + + /// The headline being bookmarked or un-bookmarked. + final Headline headline; + + /// Whether the headline is currently bookmarked. + final bool isBookmarked; + + /// The build context, used for triggering side effects. + final BuildContext context; + + @override + List get props => [headline, isBookmarked, context]; +} + +/// {@template app_content_reported} +/// Dispatched when a user reports a piece of content. +/// {@endtemplate} +class AppContentReported extends AppEvent { + /// {@macro app_content_reported} + const AppContentReported({required this.report}); + + /// The report object to be submitted. + final Report report; + + @override + List get props => [report]; +} diff --git a/lib/app/bloc/app_state.dart b/lib/app/bloc/app_state.dart index 202d7b87..9d87e614 100644 --- a/lib/app/bloc/app_state.dart +++ b/lib/app/bloc/app_state.dart @@ -20,6 +20,9 @@ class AppState extends Equatable { this.currentAppVersion, this.latestAppVersion, this.hasUnreadInAppNotifications = false, + this.positiveInteractionCount = 0, + this.limitationStatus = LimitationStatus.allowed, + this.limitedAction, }); /// The current status of the application, indicating its lifecycle stage. @@ -59,6 +62,15 @@ class AppState extends Equatable { /// A flag indicating if there are unread in-app notifications. final bool hasUnreadInAppNotifications; + /// The number of positive interactions the user has performed in the session. + final int positiveInteractionCount; + + /// The status of the most recent content limitation check. + final LimitationStatus limitationStatus; + + /// The specific action that was limited, if any. + final ContentAction? limitedAction; + /// The current theme mode (light, dark, or system), derived from [settings]. /// Defaults to [ThemeMode.system] if [settings] are not yet loaded. ThemeMode get themeMode { @@ -128,6 +140,7 @@ class AppState extends Equatable { currentAppVersion, latestAppVersion, hasUnreadInAppNotifications, + positiveInteractionCount, ]; /// Creates a copy of this [AppState] with the given fields replaced with @@ -145,6 +158,10 @@ class AppState extends Equatable { String? currentAppVersion, String? latestAppVersion, bool? hasUnreadInAppNotifications, + int? positiveInteractionCount, + LimitationStatus? limitationStatus, + ContentAction? limitedAction, + bool clearLimitedAction = false, }) { return AppState( status: status ?? this.status, @@ -161,6 +178,12 @@ class AppState extends Equatable { latestAppVersion: latestAppVersion ?? this.latestAppVersion, hasUnreadInAppNotifications: hasUnreadInAppNotifications ?? this.hasUnreadInAppNotifications, + positiveInteractionCount: + positiveInteractionCount ?? this.positiveInteractionCount, + limitationStatus: limitationStatus ?? this.limitationStatus, + limitedAction: clearLimitedAction + ? null + : limitedAction ?? this.limitedAction, ); } } diff --git a/lib/app/services/demo_data_initializer_service.dart b/lib/app/services/demo_data_initializer_service.dart index 3e75d26c..227410ef 100644 --- a/lib/app/services/demo_data_initializer_service.dart +++ b/lib/app/services/demo_data_initializer_service.dart @@ -64,7 +64,6 @@ class DemoDataInitializerService { /// user in the demo environment. Future initializeUserSpecificData(User user) async { _logger.info('Initializing user-specific data for user ID: ${user.id}'); - await Future.wait([ _ensureAppSettingsExist(user.id), _ensureUserContentPreferencesExist(user.id), @@ -81,9 +80,9 @@ class DemoDataInitializerService { Future _ensureAppSettingsExist(String userId) async { try { await _appSettingsRepository.read(id: userId, userId: userId); - _logger.info('AppSettings found for user ID: $userId.'); + _logger.finer('AppSettings found for user ID: $userId.'); } on NotFoundException { - _logger.info( + _logger.fine( 'AppSettings not found for user ID: ' '$userId. Creating settings from fixture.', ); @@ -102,7 +101,7 @@ class DemoDataInitializerService { item: fixtureSettings, userId: userId, ); - _logger.info( + _logger.fine( 'AppSettings from fixture created for ' 'user ID: $userId.', ); @@ -122,9 +121,9 @@ class DemoDataInitializerService { Future _ensureUserContentPreferencesExist(String userId) async { try { await _userContentPreferencesRepository.read(id: userId, userId: userId); - _logger.info('UserContentPreferences found for user ID: $userId.'); + _logger.finer('UserContentPreferences found for user ID: $userId.'); } on NotFoundException { - _logger.info( + _logger.fine( 'UserContentPreferences not found for ' 'user ID: $userId. Creating preferences from fixture.', ); @@ -142,7 +141,7 @@ class DemoDataInitializerService { item: fixturePreferences, userId: userId, ); - _logger.info( + _logger.fine( 'UserContentPreferences from fixture created ' 'for user ID: $userId.', ); @@ -169,11 +168,11 @@ class DemoDataInitializerService { userId: userId, ); if (existingNotifications.items.isNotEmpty) { - _logger.info('InAppNotifications already exist for user ID: $userId.'); + _logger.finer('InAppNotifications already exist for user ID: $userId.'); return; } - _logger.info( + _logger.fine( 'No InAppNotifications found for user ID: $userId. Creating from fixture.', ); @@ -198,7 +197,7 @@ class DemoDataInitializerService { (n) => _inAppNotificationRepository.create(item: n, userId: userId), ), ); - _logger.info( + _logger.fine( '${userNotifications.length} InAppNotifications from fixture created for user ID: $userId.', ); } catch (e, s) { diff --git a/lib/app/services/demo_data_migration_service.dart b/lib/app/services/demo_data_migration_service.dart index da14130e..0ae683bf 100644 --- a/lib/app/services/demo_data_migration_service.dart +++ b/lib/app/services/demo_data_migration_service.dart @@ -34,7 +34,7 @@ class DemoDataMigrationService { required String oldUserId, required String newUserId, }) async { - _logger.info( + _logger.fine( '[DemoDataMigrationService] Attempting to migrate data from ' 'anonymous user ID: $oldUserId to authenticated user ID: $newUserId', ); @@ -73,12 +73,12 @@ class DemoDataMigrationService { } await _appSettingsRepository.delete(id: oldUserId, userId: oldUserId); - _logger.info( + _logger.fine( '[DemoDataMigrationService] AppSettings migrated successfully ' 'from $oldUserId to $newUserId.', ); } on NotFoundException { - _logger.info( + _logger.fine( '[DemoDataMigrationService] No AppSettings found for old user ID: ' '$oldUserId. Skipping migration for settings.', ); @@ -128,12 +128,12 @@ class DemoDataMigrationService { id: oldUserId, userId: oldUserId, ); - _logger.info( + _logger.fine( '[DemoDataMigrationService] UserContentPreferences migrated ' 'successfully from $oldUserId to $newUserId.', ); } on NotFoundException { - _logger.info( + _logger.fine( '[DemoDataMigrationService] No UserContentPreferences found for old ' 'user ID: $oldUserId. Skipping migration for preferences.', ); diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 70ec5257..c6657c41 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -20,13 +20,14 @@ import 'package:flutter_news_app_mobile_client_full_source_code/notifications/se import 'package:flutter_news_app_mobile_client_full_source_code/router/router.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_tap_handler.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/status/view/maintenance_page.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/status/view/update_required_page.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/services/app_review_service.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template app_widget} -/// The root widget of the main application. /// /// This widget is built only after a successful startup sequence, as /// orchestrated by [AppInitializationPage]. It is responsible for setting up @@ -51,11 +52,16 @@ class App extends StatelessWidget { required DataRepository appSettingsRepository, required DataRepository userContentPreferencesRepository, + required DataRepository engagementRepository, + required DataRepository reportRepository, + required DataRepository appReviewRepository, required AppEnvironment environment, required DataRepository inAppNotificationRepository, + required ContentLimitationService contentLimitationService, required InlineAdCacheService inlineAdCacheService, required AdService adService, required FeedDecoratorService feedDecoratorService, + required AppReviewService appReviewService, required FeedCacheService feedCacheService, required GlobalKey navigatorKey, required PushNotificationService pushNotificationService, @@ -69,10 +75,15 @@ class App extends StatelessWidget { _remoteConfigRepository = remoteConfigRepository, _appSettingsRepository = appSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, + _engagementRepository = engagementRepository, + _reportRepository = reportRepository, + _appReviewRepository = appReviewRepository, _pushNotificationService = pushNotificationService, _inAppNotificationRepository = inAppNotificationRepository, + _contentLimitationService = contentLimitationService, _environment = environment, _adService = adService, + _appReviewService = appReviewService, _feedDecoratorService = feedDecoratorService, _feedCacheService = feedCacheService, _navigatorKey = navigatorKey, @@ -100,10 +111,15 @@ class App extends StatelessWidget { final DataRepository _appSettingsRepository; final DataRepository _userContentPreferencesRepository; + final DataRepository _engagementRepository; + final DataRepository _reportRepository; + final DataRepository _appReviewRepository; final AppEnvironment _environment; final DataRepository _inAppNotificationRepository; final AdService _adService; + final ContentLimitationService _contentLimitationService; final FeedDecoratorService _feedDecoratorService; + final AppReviewService _appReviewService; final FeedCacheService _feedCacheService; final GlobalKey _navigatorKey; final InlineAdCacheService _inlineAdCacheService; @@ -127,9 +143,14 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _remoteConfigRepository), RepositoryProvider.value(value: _appSettingsRepository), RepositoryProvider.value(value: _userContentPreferencesRepository), + RepositoryProvider.value(value: _engagementRepository), + RepositoryProvider.value(value: _reportRepository), + RepositoryProvider.value(value: _appReviewRepository), RepositoryProvider.value(value: _pushNotificationService), + RepositoryProvider.value(value: _contentLimitationService), RepositoryProvider.value(value: _inAppNotificationRepository), RepositoryProvider.value(value: _inlineAdCacheService), + RepositoryProvider.value(value: _appReviewService), RepositoryProvider.value(value: _feedCacheService), RepositoryProvider.value(value: _environment), // NOTE: The AppInitializer is provided at the root in bootstrap.dart @@ -154,7 +175,12 @@ class App extends StatelessWidget { pushNotificationService: _pushNotificationService, inAppNotificationRepository: _inAppNotificationRepository, userRepository: _userRepository, + appReviewService: _appReviewService, inlineAdCacheService: _inlineAdCacheService, + contentLimitationService: context + .read(), + reportRepository: context.read>(), + feedCacheService: context.read(), )..add(const AppStarted()), ), ], @@ -183,7 +209,6 @@ class _AppViewState extends State<_AppView> { StreamSubscription? _onMessageSubscription; AppStatusService? _appStatusService; InterstitialAdManager? _interstitialAdManager; - late final ContentLimitationService _contentLimitationService; late final GoRouter _router; final _routerLogger = Logger('GoRouter'); @@ -256,10 +281,10 @@ class _AppViewState extends State<_AppView> { adService: context.read(), navigatorKey: widget.navigatorKey, ); - _contentLimitationService = ContentLimitationService( - appBloc: context.read(), - ); + // Initialize the ContentLimitationService. + // Its lifecycle is now tied to this state object. + context.read().init(appBloc: appBloc); // Create the GoRouter instance once and store it. _router = createRouter( authStatusNotifier: _statusNotifier, @@ -277,6 +302,7 @@ class _AppViewState extends State<_AppView> { _appStatusService?.dispose(); _interstitialAdManager?.dispose(); context.read().close(); + context.read().dispose(); super.dispose(); } @@ -420,9 +446,6 @@ class _AppViewState extends State<_AppView> { RepositoryProvider.value( value: _interstitialAdManager!, ), - RepositoryProvider.value( - value: _contentLimitationService, - ), ], child: MaterialApp.router( debugShowCheckedModeBanner: false, diff --git a/lib/app/view/app_initialization_page.dart b/lib/app/view/app_initialization_page.dart index ce1474dd..b95a740c 100644 --- a/lib/app/view/app_initialization_page.dart +++ b/lib/app/view/app_initialization_page.dart @@ -16,7 +16,9 @@ import 'package:flutter_news_app_mobile_client_full_source_code/feed_decorators/ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/services/feed_cache_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/status/view/view.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/services/app_review_service.dart'; import 'package:logging/logging.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -57,6 +59,11 @@ class AppInitializationPage extends StatelessWidget { required this.inlineAdCacheService, required this.navigatorKey, required this.pushNotificationService, + required this.engagementRepository, + required this.reportRepository, + required this.appReviewRepository, + required this.appReviewService, + required this.contentLimitationService, required this.inAppNotificationRepository, super.key, }); @@ -77,6 +84,11 @@ class AppInitializationPage extends StatelessWidget { final GlobalKey navigatorKey; final InlineAdCacheService inlineAdCacheService; final PushNotificationService pushNotificationService; + final DataRepository engagementRepository; + final DataRepository reportRepository; + final DataRepository appReviewRepository; + final AppReviewService appReviewService; + final ContentLimitationService contentLimitationService; final DataRepository inAppNotificationRepository; @override @@ -120,6 +132,11 @@ class AppInitializationPage extends StatelessWidget { feedCacheService: feedCacheService, inlineAdCacheService: inlineAdCacheService, navigatorKey: navigatorKey, + engagementRepository: engagementRepository, + reportRepository: reportRepository, + appReviewRepository: appReviewRepository, + appReviewService: appReviewService, + contentLimitationService: contentLimitationService, ); case final AppInitializationFailed failureState: diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index f73fb1bf..2ffc145e 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -33,8 +33,12 @@ import 'package:flutter_news_app_mobile_client_full_source_code/notifications/se import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/no_op_push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/one_signal_push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/shared/data/clients/country_inmemory_client.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/data/clients/clients.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/services/app_review_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/services/native_review_service.dart'; import 'package:http_client/http_client.dart'; +import 'package:in_app_review/in_app_review.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:kv_storage_shared_preferences/kv_storage_shared_preferences.dart'; import 'package:logging/logging.dart'; @@ -216,18 +220,21 @@ Future bootstrap( late final DataClient userClient; late final DataClient inAppNotificationClient; late final DataClient pushNotificationDeviceClient; + late final DataClient engagementClient; + late final DataClient reportClient; + late final DataClient appReviewClient; if (appConfig.environment == app_config.AppEnvironment.demo) { logger.fine('Using in-memory clients for all data repositories.'); headlinesClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: headlinesFixturesData, + initialData: getHeadlinesFixturesData(), logger: logger, ); topicsClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: topicsFixturesData, + initialData: getTopicsFixturesData(), logger: logger, ); // Wrap the generic DataInMemory with CountryInMemoryClient. @@ -259,15 +266,15 @@ Future bootstrap( initialData: countriesFixturesData, logger: logger, ), - allSources: sourcesFixturesData, - allHeadlines: headlinesFixturesData, + allSources: getSourcesFixturesData(), + allHeadlines: getHeadlinesFixturesData(), ); // sourcesClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: sourcesFixturesData, + initialData: getSourcesFixturesData(), logger: logger, ); userContentPreferencesClient = DataInMemory( @@ -295,6 +302,21 @@ Future bootstrap( getId: (i) => i.id, logger: logger, ); + engagementClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + logger: logger, + ); + reportClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + logger: logger, + ); + appReviewClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + logger: logger, + ); } else { logger.fine('Using API clients for all data repositories.'); headlinesClient = DataApi( @@ -360,6 +382,27 @@ Future bootstrap( toJson: (device) => device.toJson(), logger: logger, ); + engagementClient = DataApi( + httpClient: httpClient, + modelName: 'engagement', + fromJson: Engagement.fromJson, + toJson: (engagement) => engagement.toJson(), + logger: logger, + ); + reportClient = DataApi( + httpClient: httpClient, + modelName: 'report', + fromJson: Report.fromJson, + toJson: (report) => report.toJson(), + logger: logger, + ); + appReviewClient = DataApi( + httpClient: httpClient, + modelName: 'app_review', + fromJson: AppReview.fromJson, + toJson: (review) => review.toJson(), + logger: logger, + ); } logger.fine('All data clients instantiated.'); @@ -386,6 +429,13 @@ Future bootstrap( DataRepository( dataClient: pushNotificationDeviceClient, ); + final engagementRepository = DataRepository( + dataClient: engagementClient, + ); + final reportRepository = DataRepository(dataClient: reportClient); + final appReviewRepository = DataRepository( + dataClient: appReviewClient, + ); logger ..fine('All data repositories initialized.') ..info('7. Initializing Push Notification service...'); @@ -439,6 +489,29 @@ Future bootstrap( ); } + // Initialize AppReviewService + final nativeReviewService = InAppReviewService( + inAppReview: InAppReview.instance, + logger: logger, + ); + + final appReviewService = AppReviewService( + appReviewRepository: appReviewRepository, + nativeReviewService: nativeReviewService, + logger: logger, + ); + + // Initialize ContentLimitationService. + final contentLimitationService = ContentLimitationService( + engagementRepository: engagementRepository, + reportRepository: reportRepository, + cacheDuration: const Duration(minutes: 5), + logger: logger, + ); + logger + ..fine('ContentLimitationService initialized.') + ..fine('AppReviewService initialized.'); + // Conditionally instantiate DemoDataMigrationService final demoDataMigrationService = appConfig.environment == app_config.AppEnvironment.demo @@ -464,7 +537,7 @@ Future bootstrap( inAppNotificationRepository: inAppNotificationRepository, appSettingsFixturesData: appSettingsFixturesData, userContentPreferencesFixturesData: - userContentPreferencesFixturesData, + getUserContentPreferencesFixturesData(), inAppNotificationsFixturesData: inAppNotificationsFixturesData, ) : null; @@ -521,6 +594,11 @@ Future bootstrap( inlineAdCacheService: inlineAdCacheService, feedCacheService: feedCacheService, navigatorKey: navigatorKey, + engagementRepository: engagementRepository, + reportRepository: reportRepository, + appReviewRepository: appReviewRepository, + appReviewService: appReviewService, + contentLimitationService: contentLimitationService, ), ); } diff --git a/lib/discover/bloc/source_list_bloc.dart b/lib/discover/bloc/source_list_bloc.dart index 3740eddb..5bc41964 100644 --- a/lib/discover/bloc/source_list_bloc.dart +++ b/lib/discover/bloc/source_list_bloc.dart @@ -229,10 +229,10 @@ class SourceListBloc extends Bloc { } /// Handles toggling the follow status of a source. - void _onSourceListFollowToggled( + Future _onSourceListFollowToggled( SourceListFollowToggled event, Emitter emit, - ) { + ) async { _logger.fine('[SourceListBloc] Follow toggled for ${event.source.id}.'); final preferences = _appBloc.state.userContentPreferences; if (preferences == null) { @@ -248,7 +248,7 @@ class SourceListBloc extends Bloc { // Only check limits when attempting to follow, not when unfollowing. if (!isFollowing) { - final status = _contentLimitationService.checkAction( + final status = await _contentLimitationService.checkAction( ContentAction.followSource, ); if (status != LimitationStatus.allowed) { diff --git a/lib/discover/view/source_list_page.dart b/lib/discover/view/source_list_page.dart index c199a269..bd41d48f 100644 --- a/lib/discover/view/source_list_page.dart +++ b/lib/discover/view/source_list_page.dart @@ -242,7 +242,7 @@ class _SourceListTile extends StatelessWidget { tooltip: isFollowing ? l10n.unfollowSourceTooltip(source.name) : l10n.followSourceTooltip(source.name), - onPressed: () { + onPressed: () async { // If the user is unfollowing, always allow it. if (isFollowing) { context.read().add( @@ -251,19 +251,22 @@ class _SourceListTile extends StatelessWidget { } else { // If the user is following, check the limit first. final limitationService = context.read(); - final status = limitationService.checkAction( + final status = await limitationService.checkAction( ContentAction.followSource, ); if (status == LimitationStatus.allowed) { - context.read().add( - SourceListFollowToggled(source: source), - ); + if (context.mounted) { + context.read().add( + SourceListFollowToggled(source: source), + ); + } } else { - // If the limit is reached, show the informative bottom sheet. - showModalBottomSheet( + if (!context.mounted) return; + showContentLimitationBottomSheet( context: context, - builder: (_) => ContentLimitationBottomSheet(status: status), + status: status, + action: ContentAction.followSource, ); } } diff --git a/lib/entity_details/view/entity_details_page.dart b/lib/entity_details/view/entity_details_page.dart index d574bc28..4fe1c52b 100644 --- a/lib/entity_details/view/entity_details_page.dart +++ b/lib/entity_details/view/entity_details_page.dart @@ -13,6 +13,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/feed_core.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/reporting/view/report_content_bottom_sheet.dart'; import 'package:ui_kit/ui_kit.dart'; class EntityDetailsPageArguments { @@ -46,6 +47,7 @@ class EntityDetailsView extends StatefulWidget { class _EntityDetailsViewState extends State { final _scrollController = ScrollController(); + bool _isFollowingInProgress = false; @override void initState() { @@ -103,6 +105,10 @@ class _EntityDetailsViewState extends State { final theme = Theme.of(context); final textTheme = theme.textTheme; final colorScheme = theme.colorScheme; + final remoteConfig = context.watch().state.remoteConfig; + final communityConfig = remoteConfig?.features.community; + final isSourceReportingEnabled = (communityConfig?.enabled ?? false) && + (communityConfig?.reporting.sourceReportingEnabled ?? false); return Scaffold( body: BlocBuilder( @@ -156,53 +162,86 @@ class _EntityDetailsViewState extends State { } final followButton = IconButton( - icon: Icon( - state.isFollowing ? Icons.check_circle : Icons.add_circle_outline, - color: colorScheme.primary, - ), + icon: _isFollowingInProgress + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + state.isFollowing + ? Icons.check_circle + : Icons.add_circle_outline, + color: colorScheme.primary, + ), tooltip: state.isFollowing ? l10n.unfollowButtonLabel : l10n.followButtonLabel, - onPressed: () { - // If the user is unfollowing, always allow it. - if (state.isFollowing) { - context.read().add( - const EntityDetailsToggleFollowRequested(), - ); - } else { - // If the user is following, check the limit first. - final limitationService = context - .read(); - final contentType = state.contentType; - - if (contentType == null) return; - - final action = switch (contentType) { - ContentType.topic => ContentAction.followTopic, - ContentType.source => ContentAction.followSource, - ContentType.country => ContentAction.followCountry, - _ => null, - }; - - if (action == null) { - return; - } - - final status = limitationService.checkAction(action); - - if (status == LimitationStatus.allowed) { - context.read().add( - const EntityDetailsToggleFollowRequested(), - ); - } else { - showModalBottomSheet( - context: context, - builder: (_) => - ContentLimitationBottomSheet(status: status), - ); - } - } - }, + onPressed: _isFollowingInProgress + ? null + : () async { + setState(() => _isFollowingInProgress = true); + try { + // If the user is unfollowing, always allow it. + if (state.isFollowing) { + context.read().add( + const EntityDetailsToggleFollowRequested(), + ); + return; + } + + // If the user is following, check the limit first. + final limitationService = context + .read(); + final contentType = state.contentType; + + if (contentType == null) return; + + final action = switch (contentType) { + ContentType.topic => ContentAction.followTopic, + ContentType.source => ContentAction.followSource, + ContentType.country => ContentAction.followCountry, + _ => null, + }; + + if (action == null) return; + + final status = await limitationService.checkAction( + action, + ); + + if (status != LimitationStatus.allowed) { + if (!mounted) return; + showContentLimitationBottomSheet( + context: context, + status: status, + action: action, + ); + return; + } + + context.read().add( + const EntityDetailsToggleFollowRequested(), + ); + context.read().add( + AppPositiveInteractionOcurred(context: context), + ); + } on ForbiddenException catch (e) { + if (!mounted) return; + await showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: l10n.limitReachedTitle, + body: e.message, + buttonText: l10n.gotItButton, + ), + ); + } finally { + if (mounted) { + setState(() => _isFollowingInProgress = false); + } + } + }, ); final entityIconUrl = switch (state.entity) { @@ -255,7 +294,31 @@ class _EntityDetailsViewState extends State { snap: false, actions: [ followButton, - const SizedBox(width: AppSpacing.sm), + if (widget.args.contentType == ContentType.source && + isSourceReportingEnabled) + PopupMenuButton( + onSelected: (value) { + if (value == 'report') { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ReportContentBottomSheet( + entityId: widget.args.entityId, + reportableEntity: ReportableEntity.source, + ), + ); + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'report', + child: Text(l10n.reportActionLabel), + ), + ], + ) + else + const SizedBox.shrink(), ], ), if (state.feedItems.isEmpty && diff --git a/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart b/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart index 1957ea08..48ec9c1f 100644 --- a/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart +++ b/lib/feed_decorators/widgets/feed_decorator_loader_widget.dart @@ -11,6 +11,8 @@ import 'package:flutter_news_app_mobile_client_full_source_code/feed_decorators/ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:logging/logging.dart'; import 'package:ui_kit/ui_kit.dart'; import 'package:uuid/uuid.dart'; @@ -195,7 +197,7 @@ class _FeedDecoratorLoaderWidgetState extends State { /// The logic here ensures that the UI layer (this widget) constructs the /// desired new state, and the [AppBloc] is responsible for persisting it, /// maintaining consistency with the `AppSettingsChanged` pattern. - void _onFollowToggle(FeedItem item) { + Future _onFollowToggle(FeedItem item) async { _logger.fine( '[FeedDecoratorLoaderWidget] _onFollowToggle called for item of type: ${item.runtimeType}', ); @@ -213,64 +215,93 @@ class _FeedDecoratorLoaderWidgetState extends State { return; } - if (item is Topic) { - final topic = item; - // Create a mutable copy of the followed topics list. - final currentFollowedTopics = List.from( - userContentPreferences.followedTopics, - ); + final l10n = AppLocalizationsX(context).l10n; - if (currentFollowedTopics.any((t) => t.id == topic.id)) { - // If already following, unfollow. - currentFollowedTopics.removeWhere((t) => t.id == topic.id); - _logger.info( - '[FeedDecoratorLoaderWidget] Unfollowed topic: ${topic.id}', + try { + if (item is Topic) { + final topic = item; + final currentFollowedTopics = List.from( + userContentPreferences.followedTopics, ); - } else { - // If not following, follow. - currentFollowedTopics.add(topic); - _logger.info('[FeedDecoratorLoaderWidget] Followed topic: ${topic.id}'); - } - // Create a new UserContentPreferences object with the updated topics. - context.read().add( - AppUserContentPreferencesChanged( - preferences: userContentPreferences.copyWith( - followedTopics: currentFollowedTopics, + final isFollowing = currentFollowedTopics.any((t) => t.id == topic.id); + + if (isFollowing) { + currentFollowedTopics.removeWhere((t) => t.id == topic.id); + } else { + final limitationService = context.read(); + final status = await limitationService.checkAction( + ContentAction.followTopic, + ); + if (status != LimitationStatus.allowed) { + if (mounted) { + showContentLimitationBottomSheet( + context: context, + status: status, + action: ContentAction.followTopic, + ); + } + return; + } + currentFollowedTopics.add(topic); + } + context.read().add( + AppUserContentPreferencesChanged( + preferences: userContentPreferences.copyWith( + followedTopics: currentFollowedTopics, + ), ), - ), - ); - } else if (item is Source) { - final source = item; - // Create a mutable copy of the followed sources list. - final currentFollowedSources = List.from( - userContentPreferences.followedSources, - ); + ); + } else if (item is Source) { + final source = item; + final currentFollowedSources = List.from( + userContentPreferences.followedSources, + ); + final isFollowing = currentFollowedSources.any( + (s) => s.id == source.id, + ); - if (currentFollowedSources.any((s) => s.id == source.id)) { - // If already following, unfollow. - currentFollowedSources.removeWhere((s) => s.id == source.id); - _logger.info( - '[FeedDecoratorLoaderWidget] Unfollowed source: ${source.id}', + if (isFollowing) { + currentFollowedSources.removeWhere((s) => s.id == source.id); + } else { + final limitationService = context.read(); + final status = await limitationService.checkAction( + ContentAction.followSource, + ); + if (status != LimitationStatus.allowed) { + if (mounted) { + showContentLimitationBottomSheet( + context: context, + status: status, + action: ContentAction.followSource, + ); + } + return; + } + currentFollowedSources.add(source); + } + context.read().add( + AppUserContentPreferencesChanged( + preferences: userContentPreferences.copyWith( + followedSources: currentFollowedSources, + ), + ), ); } else { - // If not following, follow. - currentFollowedSources.add(source); - _logger.info( - '[FeedDecoratorLoaderWidget] Followed source: ${source.id}', + _logger.warning( + '[FeedDecoratorLoaderWidget] Unsupported FeedItem type for follow toggle: ${item.runtimeType}', ); - } // Create a new UserContentPreferences object with the updated sources. - context.read().add( - AppUserContentPreferencesChanged( - preferences: userContentPreferences.copyWith( - followedSources: currentFollowedSources, + } + } on ForbiddenException catch (e) { + if (mounted) { + await showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: l10n.limitReachedTitle, + body: e.message, + buttonText: l10n.gotItButton, ), - ), - ); - } else { - _logger.warning( - '[FeedDecoratorLoaderWidget] Unsupported FeedItem type for follow toggle: ${item.runtimeType}', - ); - return; + ); + } } } diff --git a/lib/headlines-feed/bloc/headlines_feed_bloc.dart b/lib/headlines-feed/bloc/headlines_feed_bloc.dart index 98b43bdc..6eb2bac6 100644 --- a/lib/headlines-feed/bloc/headlines_feed_bloc.dart +++ b/lib/headlines-feed/bloc/headlines_feed_bloc.dart @@ -6,6 +6,7 @@ import 'package:collection/collection.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/ad_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/inline_ad_cache_service.dart'; @@ -13,7 +14,10 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_blo import 'package:flutter_news_app_mobile_client_full_source_code/feed_decorators/services/feed_decorator_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/models/cached_feed.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/services/feed_cache_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; part 'headlines_feed_event.dart'; part 'headlines_feed_state.dart'; @@ -65,17 +69,21 @@ class HeadlinesFeedBloc extends Bloc { HeadlinesFeedBloc({ required DataRepository headlinesRepository, required FeedDecoratorService feedDecoratorService, + required DataRepository engagementRepository, required AdService adService, required AppBloc appBloc, required InlineAdCacheService inlineAdCacheService, required FeedCacheService feedCacheService, + required ContentLimitationService contentLimitationService, UserContentPreferences? initialUserContentPreferences, }) : _headlinesRepository = headlinesRepository, _feedDecoratorService = feedDecoratorService, + _engagementRepository = engagementRepository, _adService = adService, _appBloc = appBloc, _inlineAdCacheService = inlineAdCacheService, _feedCacheService = feedCacheService, + _contentLimitationService = contentLimitationService, _logger = Logger('HeadlinesFeedBloc'), super( HeadlinesFeedState( @@ -86,23 +94,18 @@ class HeadlinesFeedBloc extends Bloc { // Subscribe to AppBloc to react to global state changes, primarily for // keeping the feed's list of saved filters synchronized with the global // app state. - _appBlocSubscription = _appBloc.stream.listen((appState) { - // This subscription is now responsible for handling *updates* to the - // user's preferences while the bloc is active. The initial state is - // handled by the constructor. - // This subscription's responsibility is to listen for changes in user - // preferences (like adding/removing a saved filter) from other parts - // of the app and update this BLoC's state accordingly. - if (appState.userContentPreferences?.savedHeadlineFilters != null && - state.savedHeadlineFilters != - appState.userContentPreferences!.savedHeadlineFilters) { - add( - _AppContentPreferencesChanged( - preferences: appState.userContentPreferences!, - ), - ); - } - }); + _appBlocSubscription = _appBloc.stream + .map((appState) => appState.userContentPreferences) + .distinct() + .listen((preferences) { + // This subscription's responsibility is to listen for changes in + // user preferences (like adding/removing a saved filter) from other + // parts of the app and update this BLoC's state accordingly. + if (preferences != null && + state.savedHeadlineFilters != preferences.savedHeadlineFilters) { + add(_AppContentPreferencesChanged(preferences: preferences)); + } + }); on( _onHeadlinesFeedStarted, @@ -136,6 +139,22 @@ class HeadlinesFeedBloc extends Bloc { _onFollowedFilterSelected, transformer: restartable(), ); + on( + _onHeadlinesFeedEngagementTapped, + transformer: sequential(), + ); + on( + _onHeadlinesFeedReactionUpdated, + transformer: sequential(), + ); + on( + _onHeadlinesFeedCommentPosted, + transformer: sequential(), + ); + on( + _onHeadlinesFeedCommentUpdated, + transformer: sequential(), + ); } final DataRepository _headlinesRepository; @@ -144,10 +163,12 @@ class HeadlinesFeedBloc extends Bloc { final AppBloc _appBloc; final InlineAdCacheService _inlineAdCacheService; final FeedCacheService _feedCacheService; + final DataRepository _engagementRepository; + final ContentLimitationService _contentLimitationService; final Logger _logger; /// Subscription to the AppBloc's state stream. - late final StreamSubscription _appBlocSubscription; + late final StreamSubscription _appBlocSubscription; static const _allFilterId = 'all'; @@ -184,6 +205,25 @@ class HeadlinesFeedBloc extends Bloc { return queryFilter; } + /// Fetches engagements for a list of headline IDs and returns them as a map. + Future>> _fetchEngagementsForHeadlines( + List headlineIds, + ) async { + if (headlineIds.isEmpty) return {}; + try { + final response = await _engagementRepository.readAll( + filter: { + 'entityId': {r'$in': headlineIds}, + }, + ); + // Group engagements by their entityId. + return groupBy(response.items, (e) => e.entityId); + } catch (e, s) { + _logger.severe('Failed to fetch engagements for headlines.', e, s); + return {}; // Return empty map on failure to avoid breaking the feed. + } + } + /// Generates a deterministic cache key based on the active filter. String _generateFilterKey( String activeFilterId, @@ -209,6 +249,8 @@ class HeadlinesFeedBloc extends Bloc { HeadlinesFeedStarted event, Emitter emit, ) async { + // Persist the ad theme style in the state for background processes. + emit(state.copyWith(adThemeStyle: event.adThemeStyle)); add(HeadlinesFeedRefreshRequested(adThemeStyle: event.adThemeStyle)); } @@ -257,6 +299,13 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); + final newEngagements = await _fetchEngagementsForHeadlines( + headlineResponse.items.map((h) => h.id).toList(), + ); + final updatedEngagementsMap = Map>.from( + state.engagementsMap, + )..addAll(newEngagements); + // For pagination, only inject ad placeholders. final newProcessedFeedItems = await _adService.injectFeedAdPlaceholders( feedItems: headlineResponse.items, @@ -295,6 +344,7 @@ class HeadlinesFeedBloc extends Bloc { feedItems: updatedFeedItems, hasMore: headlineResponse.hasMore, cursor: headlineResponse.cursor, + engagementsMap: updatedEngagementsMap, ), ); } on HttpException catch (e) { @@ -326,7 +376,12 @@ class HeadlinesFeedBloc extends Bloc { // On a full refresh, clear the ad cache for the current context to ensure // fresh ads are loaded. _inlineAdCacheService.clearAdsForContext(contextKey: filterKey); - emit(state.copyWith(status: HeadlinesFeedStatus.loading)); + emit( + state.copyWith( + status: HeadlinesFeedStatus.loading, + adThemeStyle: event.adThemeStyle, + ), + ); try { final currentUser = _appBloc.state.user; final appConfig = _appBloc.state.remoteConfig; @@ -342,6 +397,13 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); + final newEngagements = await _fetchEngagementsForHeadlines( + headlineResponse.items.map((h) => h.id).toList(), + ); + final updatedEngagementsMap = Map>.from( + state.engagementsMap, + )..addAll(newEngagements); + _logger.info( 'Refresh: Fetched ${headlineResponse.items.length} latest headlines ' 'for filter "$filterKey".', @@ -380,6 +442,7 @@ class HeadlinesFeedBloc extends Bloc { emit( state.copyWith( status: HeadlinesFeedStatus.success, + engagementsMap: updatedEngagementsMap, feedItems: updatedFeedItems, ), ); @@ -448,6 +511,7 @@ class HeadlinesFeedBloc extends Bloc { hasMore: newCachedFeed.hasMore, cursor: newCachedFeed.cursor, filter: state.filter, + engagementsMap: updatedEngagementsMap, ), ); } on HttpException catch (e) { @@ -540,6 +604,7 @@ class HeadlinesFeedBloc extends Bloc { filter: event.filter, activeFilterId: newActiveFilterId, status: HeadlinesFeedStatus.loading, + adThemeStyle: event.adThemeStyle, feedItems: [], clearCursor: true, ), @@ -559,6 +624,13 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); + final newEngagements = await _fetchEngagementsForHeadlines( + headlineResponse.items.map((h) => h.id).toList(), + ); + final updatedEngagementsMap = Map>.from( + state.engagementsMap, + )..addAll(newEngagements); + final settings = _appBloc.state.settings; // Step 1: Inject the decorator placeholder. @@ -591,6 +663,7 @@ class HeadlinesFeedBloc extends Bloc { feedItems: newCachedFeed.feedItems, hasMore: newCachedFeed.hasMore, cursor: newCachedFeed.cursor, + engagementsMap: updatedEngagementsMap, ), ); } on HttpException catch (e) { @@ -644,6 +717,7 @@ class HeadlinesFeedBloc extends Bloc { status: HeadlinesFeedStatus.loading, filter: newFilter, activeFilterId: _allFilterId, + adThemeStyle: event.adThemeStyle, feedItems: [], clearCursor: true, ), @@ -663,6 +737,13 @@ class HeadlinesFeedBloc extends Bloc { sort: [const SortOption('updatedAt', SortOrder.desc)], ); + final newEngagements = await _fetchEngagementsForHeadlines( + headlineResponse.items.map((h) => h.id).toList(), + ); + final updatedEngagementsMap = Map>.from( + state.engagementsMap, + )..addAll(newEngagements); + final settings = _appBloc.state.settings; // Step 1: Inject the decorator placeholder. @@ -695,6 +776,7 @@ class HeadlinesFeedBloc extends Bloc { feedItems: newCachedFeed.feedItems, hasMore: newCachedFeed.hasMore, cursor: newCachedFeed.cursor, + engagementsMap: updatedEngagementsMap, ), ); } on HttpException catch (e) { @@ -713,7 +795,9 @@ class HeadlinesFeedBloc extends Bloc { NavigationHandled event, Emitter emit, ) { - emit(state.copyWith(clearNavigationUrl: true)); + emit( + state.copyWith(clearNavigationUrl: true, clearNavigationArguments: true), + ); } void _onAppContentPreferencesChanged( @@ -771,6 +855,7 @@ class HeadlinesFeedBloc extends Bloc { add( HeadlinesFeedFiltersApplied( filter: newFilter, + savedHeadlineFilter: event.filter.isPinned ? null : event.filter, adThemeStyle: event.adThemeStyle, ), ); @@ -867,6 +952,244 @@ class HeadlinesFeedBloc extends Bloc { add(HeadlinesFeedRefreshRequested(adThemeStyle: event.adThemeStyle)); } + void _onHeadlinesFeedEngagementTapped( + HeadlinesFeedEngagementTapped event, + Emitter emit, + ) { + // The UI will listen for this state change and trigger navigation. + emit( + state.copyWith( + navigationUrl: + '${Routes.feed}/${Routes.engagement.replaceFirst(':', '')}${event.headline.id}', + navigationArguments: event.headline, + ), + ); + } + + Future _onHeadlinesFeedReactionUpdated( + HeadlinesFeedReactionUpdated event, + Emitter emit, + ) async { + final userId = _appBloc.state.user?.id; + if (userId == null) return; + + final preCheckStatus = await _contentLimitationService.checkAction( + ContentAction.reactToContent, + ); + + if (preCheckStatus != LimitationStatus.allowed) { + _logger.warning('Reaction limit reached for user $userId.'); + emit( + state.copyWith( + limitationStatus: preCheckStatus, + limitedAction: ContentAction.reactToContent, + ), + ); + emit(state.copyWith(clearLimitedAction: true)); + return; + } + + final currentEngagements = state.engagementsMap[event.headlineId] ?? []; + final userEngagement = currentEngagements.firstWhereOrNull( + (e) => e.userId == userId, + ); + + try { + Engagement? updatedEngagement; + + if (userEngagement != null) { + final isTogglingOff = + event.reactionType == null || + userEngagement.reaction?.reactionType == event.reactionType; + + if (isTogglingOff) { + if (userEngagement.comment == null) { + await _engagementRepository.delete( + id: userEngagement.id, + userId: userId, + ); + updatedEngagement = null; // It's deleted + } else { + updatedEngagement = userEngagement.copyWith( + reaction: const ValueWrapper(null), + ); + await _engagementRepository.update( + id: updatedEngagement.id, + item: updatedEngagement, + userId: userId, + ); + } + } else { + updatedEngagement = userEngagement.copyWith( + reaction: ValueWrapper(Reaction(reactionType: event.reactionType!)), + ); + await _engagementRepository.update( + id: updatedEngagement.id, + item: updatedEngagement, + userId: userId, + ); + } + } else if (event.reactionType != null) { + updatedEngagement = Engagement( + id: const Uuid().v4(), + userId: userId, + entityId: event.headlineId, + entityType: EngageableType.headline, + reaction: Reaction(reactionType: event.reactionType!), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + await _engagementRepository.create( + item: updatedEngagement, + userId: userId, + ); + } + + // Optimistically update the state + final newEngagementsForHeadline = List.from( + currentEngagements, + )..removeWhere((e) => e.userId == userId); + if (updatedEngagement != null) { + newEngagementsForHeadline.add(updatedEngagement); + } + + final newEngagementsMap = Map>.from( + state.engagementsMap, + )..[event.headlineId] = newEngagementsForHeadline; + + emit(state.copyWith(engagementsMap: newEngagementsMap)); + _appBloc.add(AppPositiveInteractionOcurred(context: event.context)); + } catch (e, s) { + _logger.severe('Failed to update reaction.', e, s); + } + } + + Future _onHeadlinesFeedCommentPosted( + HeadlinesFeedCommentPosted event, + Emitter emit, + ) async { + final userId = _appBloc.state.user?.id; + final language = _appBloc.state.settings?.language; + if (userId == null || language == null) return; + + final preCheckStatus = await _contentLimitationService.checkAction( + ContentAction.postComment, + ); + if (preCheckStatus != LimitationStatus.allowed) { + _logger.warning('Comment limit reached for user $userId.'); + emit( + state.copyWith( + limitationStatus: preCheckStatus, + limitedAction: ContentAction.postComment, + ), + ); + emit(state.copyWith(clearLimitedAction: true)); + return; + } + + final currentEngagements = state.engagementsMap[event.headlineId] ?? []; + final userEngagement = currentEngagements.firstWhereOrNull( + (e) => e.userId == userId, + ); + + final newComment = Comment(language: language, content: event.content); + final engagementToUpsert = + (userEngagement ?? + Engagement( + id: const Uuid().v4(), + userId: userId, + entityId: event.headlineId, + entityType: EngageableType.headline, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + )) + .copyWith(comment: ValueWrapper(newComment)); + + // Optimistic UI update + final newEngagementsForHeadline = List.from(currentEngagements) + ..removeWhere((e) => e.userId == userId) + ..add(engagementToUpsert); + + final newEngagementsMap = Map>.from( + state.engagementsMap, + )..[event.headlineId] = newEngagementsForHeadline; + + emit(state.copyWith(engagementsMap: newEngagementsMap)); + + try { + if (userEngagement != null) { + await _engagementRepository.update( + id: engagementToUpsert.id, + item: engagementToUpsert, + userId: userId, + ); + } else { + await _engagementRepository.create( + item: engagementToUpsert, + userId: userId, + ); + } + _appBloc.add(AppPositiveInteractionOcurred(context: event.context)); + } catch (e, s) { + _logger.severe('Failed to post comment.', e, s); + // Revert optimistic update on failure + emit(state.copyWith(engagementsMap: state.engagementsMap)); + } + } + + Future _onHeadlinesFeedCommentUpdated( + HeadlinesFeedCommentUpdated event, + Emitter emit, + ) async { + final userId = _appBloc.state.user?.id; + final language = _appBloc.state.settings?.language; + if (userId == null || language == null) return; + + final currentEngagements = state.engagementsMap[event.headlineId] ?? []; + final userEngagement = currentEngagements.firstWhereOrNull( + (e) => e.userId == userId, + ); + + // If there's no existing engagement or no comment to update, do nothing. + if (userEngagement == null || userEngagement.comment == null) { + _logger.warning( + 'Comment update requested for headline ${event.headlineId} but no ' + 'existing comment found for user $userId.', + ); + return; + } + + final updatedComment = userEngagement.comment!.copyWith( + content: event.content, + ); + final updatedEngagement = userEngagement.copyWith( + comment: ValueWrapper(updatedComment), + ); + + // Optimistic UI update + final newEngagementsForHeadline = List.from(currentEngagements) + ..removeWhere((e) => e.userId == userId) + ..add(updatedEngagement); + + final newEngagementsMap = Map>.from( + state.engagementsMap, + )..[event.headlineId] = newEngagementsForHeadline; + + emit(state.copyWith(engagementsMap: newEngagementsMap)); + + try { + await _engagementRepository.update( + id: updatedEngagement.id, + item: updatedEngagement, + userId: userId, + ); + _appBloc.add(AppPositiveInteractionOcurred(context: event.context)); + } catch (e, s) { + _logger.severe('Failed to update comment.', e, s); + emit(state.copyWith(engagementsMap: state.engagementsMap)); + } + } + @override Future close() { _appBlocSubscription.cancel(); diff --git a/lib/headlines-feed/bloc/headlines_feed_event.dart b/lib/headlines-feed/bloc/headlines_feed_event.dart index 06ba2ca1..d8aa01ec 100644 --- a/lib/headlines-feed/bloc/headlines_feed_event.dart +++ b/lib/headlines-feed/bloc/headlines_feed_event.dart @@ -191,3 +191,89 @@ final class _AppContentPreferencesChanged extends HeadlinesFeedEvent { @override List get props => [preferences]; } + +/// {@template headlines_feed_engagement_tapped} +/// Dispatched when the user taps the comment icon on a headline. +/// {@endtemplate} +final class HeadlinesFeedEngagementTapped extends HeadlinesFeedEvent { + /// {@macro headlines_feed_engagement_tapped} + const HeadlinesFeedEngagementTapped({required this.headline}); + + /// The headline for which to show the engagement sheet. + final Headline headline; + + @override + List get props => [headline]; +} + +/// {@template headlines_feed_reaction_updated} +/// Dispatched when the user selects or updates their reaction on a headline. +/// {@endtemplate} +final class HeadlinesFeedReactionUpdated extends HeadlinesFeedEvent { + /// {@macro headlines_feed_reaction_updated} + const HeadlinesFeedReactionUpdated( + this.headlineId, + this.reactionType, { + required this.context, + }); + + /// The ID of the headline being reacted to. + final String headlineId; + + /// The new reaction type selected by the user. Can be null if toggling off. + final ReactionType? reactionType; + + /// The build context, used for triggering side effects like review prompts. + final BuildContext context; + + @override + List get props => [headlineId, reactionType, context]; +} + +/// {@template headlines_feed_comment_posted} +/// Dispatched when the user posts a new comment on a headline. +/// {@endtemplate} +final class HeadlinesFeedCommentPosted extends HeadlinesFeedEvent { + /// {@macro headlines_feed_comment_posted} + const HeadlinesFeedCommentPosted( + this.headlineId, + this.content, { + required this.context, + }); + + /// The ID of the headline being commented on. + final String headlineId; + + /// The text content of the comment. + final String content; + + /// The build context, used for triggering side effects like review prompts. + final BuildContext context; + + @override + List get props => [headlineId, content, context]; +} + +/// {@template headlines_feed_comment_updated} +/// Dispatched when the user updates their existing comment on a headline. +/// {@endtemplate} +final class HeadlinesFeedCommentUpdated extends HeadlinesFeedEvent { + /// {@macro headlines_feed_comment_updated} + const HeadlinesFeedCommentUpdated( + this.headlineId, + this.content, { + required this.context, + }); + + /// The ID of the headline being commented on. + final String headlineId; + + /// The updated text content of the comment. + final String content; + + /// The build context, used for triggering side effects like review prompts. + final BuildContext context; + + @override + List get props => [headlineId, content, context]; +} diff --git a/lib/headlines-feed/bloc/headlines_feed_state.dart b/lib/headlines-feed/bloc/headlines_feed_state.dart index db13a487..bbca0976 100644 --- a/lib/headlines-feed/bloc/headlines_feed_state.dart +++ b/lib/headlines-feed/bloc/headlines_feed_state.dart @@ -17,6 +17,11 @@ class HeadlinesFeedState extends Equatable { this.activeFilterId = 'all', this.error, this.navigationUrl, + this.navigationArguments, + this.adThemeStyle, + this.engagementsMap = const {}, + this.limitationStatus = LimitationStatus.allowed, + this.limitedAction, }); final HeadlinesFeedStatus status; @@ -32,6 +37,10 @@ class HeadlinesFeedState extends Equatable { /// The UI should consume this and then clear it. final String? navigationUrl; + /// Optional arguments to pass during navigation. This is used to pass + /// complex objects like the `Headline` model to the engagement sheet. + final Object? navigationArguments; + /// The list of saved headlines filters available to the user. /// This is synced from the [AppBloc]. final List savedHeadlineFilters; @@ -40,6 +49,19 @@ class HeadlinesFeedState extends Equatable { /// Can be a [SavedHeadlineFilter.id], 'all', or 'custom'. final String? activeFilterId; + /// The current ad theme style. + final AdThemeStyle? adThemeStyle; + + /// A map of engagements, where the key is the entity ID (e.g., headline ID) + /// and the value is the list of engagements for that entity. + final Map> engagementsMap; + + /// The status of the most recent content limitation check. + final LimitationStatus limitationStatus; + + /// The specific action that was limited, if any. + final ContentAction? limitedAction; + HeadlinesFeedState copyWith({ HeadlinesFeedStatus? status, List? feedItems, @@ -51,8 +73,15 @@ class HeadlinesFeedState extends Equatable { HttpException? error, String? navigationUrl, bool clearCursor = false, + Object? navigationArguments, bool clearActiveFilterId = false, bool clearNavigationUrl = false, + AdThemeStyle? adThemeStyle, + bool clearNavigationArguments = false, + Map>? engagementsMap, + LimitationStatus? limitationStatus, + ContentAction? limitedAction, + bool clearLimitedAction = false, }) { return HeadlinesFeedState( status: status ?? this.status, @@ -68,6 +97,15 @@ class HeadlinesFeedState extends Equatable { navigationUrl: clearNavigationUrl ? null : navigationUrl ?? this.navigationUrl, + navigationArguments: clearNavigationArguments + ? null + : navigationArguments ?? this.navigationArguments, + adThemeStyle: adThemeStyle ?? this.adThemeStyle, + engagementsMap: engagementsMap ?? this.engagementsMap, + limitationStatus: limitationStatus ?? this.limitationStatus, + limitedAction: clearLimitedAction + ? null + : limitedAction ?? this.limitedAction, ); } @@ -82,5 +120,10 @@ class HeadlinesFeedState extends Equatable { activeFilterId, error, navigationUrl, + navigationArguments, + adThemeStyle, + engagementsMap, + limitationStatus, + limitedAction, ]; } diff --git a/lib/headlines-feed/models/cached_feed.dart b/lib/headlines-feed/models/cached_feed.dart index a27f6d3f..e29253c0 100644 --- a/lib/headlines-feed/models/cached_feed.dart +++ b/lib/headlines-feed/models/cached_feed.dart @@ -56,7 +56,7 @@ class CachedFeed extends Equatable { 'feedItems: ${feedItems.length} items, ' 'hasMore: $hasMore, ' 'cursor: $cursor, ' - 'lastRefreshedAt: $lastRefreshedAt' + 'lastRefreshedAt: $lastRefreshedAt, ' ')'; } } diff --git a/lib/headlines-feed/view/headlines_feed_page.dart b/lib/headlines-feed/view/headlines_feed_page.dart index de818c73..ee558249 100644 --- a/lib/headlines-feed/view/headlines_feed_page.dart +++ b/lib/headlines-feed/view/headlines_feed_page.dart @@ -12,6 +12,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/w import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/widgets/saved_filters_bar.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/shared.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/engagement/view/comments_bottom_sheet.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -113,11 +114,32 @@ class _HeadlinesFeedPageState extends State final theme = Theme.of(context); return BlocListener( + listenWhen: (previous, current) => + previous.navigationUrl != current.navigationUrl || + previous.limitationStatus != current.limitationStatus, listener: (context, state) { + if (state.limitationStatus != LimitationStatus.allowed) { + showContentLimitationBottomSheet( + context: context, + status: state.limitationStatus, + action: state.limitedAction ?? ContentAction.reactToContent, + ); + return; + } + + // This listener handles navigation actions triggered by the BLoC. if (state.navigationUrl != null) { - // Use context.push for navigation to allow returning. - // This is suitable for call-to-action flows like linking an account. - context.push(state.navigationUrl!); + final navArgs = state.navigationArguments; + if (navArgs is Headline) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => CommentsBottomSheet(headlineId: navArgs.id), + ); + } else { + // Handle simple URL navigation for call-to-actions. + context.push(state.navigationUrl!); + } // Notify the BLoC that navigation has been handled to clear the URL. context.read().add(NavigationHandled()); } diff --git a/lib/headlines-feed/view/headlines_filter_page.dart b/lib/headlines-feed/view/headlines_filter_page.dart index 2988cb6b..f4d73feb 100644 --- a/lib/headlines-feed/view/headlines_filter_page.dart +++ b/lib/headlines-feed/view/headlines_filter_page.dart @@ -156,18 +156,23 @@ Future _onApplyTapped( Future _createAndApplyFilter(BuildContext context) async { // Before showing the save dialog, check if the user is allowed to save // another filter based on their subscription level and current usage. + final l10n = AppLocalizations.of(context); final contentLimitationService = context.read(); - final limitationStatus = contentLimitationService.checkAction( - ContentAction.saveHeadlineFilter, + final limitationStatus = await contentLimitationService.checkAction( + ContentAction.saveFilter, ); // If the user has reached their limit, show the limitation bottom sheet // and halt the save process. if (limitationStatus != LimitationStatus.allowed) { + if (!context.mounted) return; await showModalBottomSheet( context: context, - builder: (context) => - ContentLimitationBottomSheet(status: limitationStatus), + builder: (context) => ContentLimitationBottomSheet( + title: l10n.premiumLimitTitle, + body: l10n.limitReachedBodySaveFilters, + buttonText: l10n.premiumLimitButton, + ), ); return; } diff --git a/lib/headlines-feed/widgets/save_filter_dialog.dart b/lib/headlines-feed/widgets/save_filter_dialog.dart index 5274bab0..df71de7c 100644 --- a/lib/headlines-feed/widgets/save_filter_dialog.dart +++ b/lib/headlines-feed/widgets/save_filter_dialog.dart @@ -6,6 +6,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localiz import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/content_limitation_bottom_sheet.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template save_filter_dialog} @@ -44,10 +45,8 @@ class _SaveFilterDialogState extends State { late bool _isPinned; late Set _selectedDeliveryTypes; - // --- State flags to control UI based on user limits --- - // Determines if the user can interact with the 'Pin to feed' switch. - late final bool _canPin; - // Determines if the user can interact with each notification type checkbox. + bool _isSaving = false; + bool _canPin = true; late final Map _canSubscribePerType; @@ -62,45 +61,49 @@ class _SaveFilterDialogState extends State { // Initialize with a new modifiable Set to prevent "Cannot modify // unmodifiable Set" errors when editing an existing filter. _selectedDeliveryTypes = Set.from(filter?.deliveryTypes ?? {}); + _canSubscribePerType = {}; + _checkLimits(); + } - // Check content limitations to dynamically enable/disable UI controls. + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _checkLimits() async { final contentLimitationService = context.read(); - // The user can interact with the pin switch if they haven't reached their - // limit, OR if they are editing a filter that is already pinned (to allow - // them to un-pin it). - _canPin = - contentLimitationService.checkAction(ContentAction.pinHeadlineFilter) == - LimitationStatus.allowed || - (widget.filterToEdit?.isPinned ?? false); + final canPinStatus = await contentLimitationService.checkAction( + ContentAction.pinFilter, + ); + if (mounted) { + setState(() { + _canPin = + canPinStatus == LimitationStatus.allowed || + (widget.filterToEdit?.isPinned == true); + }); + } - // Determine subscribability for each notification type individually. - _canSubscribePerType = {}; for (final type in PushNotificationSubscriptionDeliveryType.values) { final isAlreadySubscribed = widget.filterToEdit?.deliveryTypes.contains(type) ?? false; - - // Check if the user is allowed to subscribe to this specific type. - final limitationStatus = contentLimitationService.checkAction( - ContentAction.subscribeToHeadlineFilterNotifications, + final limitationStatus = await contentLimitationService.checkAction( + ContentAction.subscribeToSavedFilterNotifications, deliveryType: type, ); - - // The user can interact with the checkbox if they haven't reached their - // limit for this type, OR if they are already subscribed to it (to - // allow them to un-subscribe). - _canSubscribePerType[type] = - limitationStatus == LimitationStatus.allowed || isAlreadySubscribed; + if (mounted) { + setState(() { + _canSubscribePerType[type] = + limitationStatus == LimitationStatus.allowed || + isAlreadySubscribed; + }); + } } } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - Future _submitForm() async { + final l10n = AppLocalizationsX(context).l10n; if (_formKey.currentState!.validate()) { // If the user has selected any notification types but permission has // not been granted, we initiate the lazy permission request flow. @@ -152,18 +155,54 @@ class _SaveFilterDialogState extends State { } } - // Once permissions are confirmed (or were not needed), proceed with saving. - widget.onSave(( - name: _controller.text.trim(), - isPinned: _isPinned, - deliveryTypes: _selectedDeliveryTypes, - )); + setState(() => _isSaving = true); + + try { + final limitationService = context.read(); + final status = await limitationService.checkAction( + ContentAction.saveFilter, + ); + + if (status != LimitationStatus.allowed && widget.filterToEdit == null) { + if (mounted) { + showContentLimitationBottomSheet( + context: context, + status: status, + action: ContentAction.saveFilter, + ); + } + return; + } + + widget.onSave(( + name: _controller.text.trim(), + isPinned: _isPinned, + deliveryTypes: _selectedDeliveryTypes, + )); + + if (mounted) { + Navigator.of(context).pop(true); + } + } on ForbiddenException catch (e) { + if (mounted) { + await showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: l10n.limitReachedTitle, + body: e.message, + buttonText: l10n.gotItButton, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } // Pop the dialog and return `true` to signal to the caller that the // save operation was successfully initiated. This allows the caller // to coordinate subsequent navigation actions, preventing race conditions. - if (!mounted) return; - Navigator.of(context).pop(true); } } @@ -277,7 +316,15 @@ class _SaveFilterDialogState extends State { onPressed: () => Navigator.of(context).pop(), child: Text(l10n.cancelButtonLabel), ), - FilledButton(onPressed: _submitForm, child: Text(l10n.saveButtonLabel)), + FilledButton( + onPressed: _isSaving ? null : _submitForm, + child: _isSaving + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : Text(l10n.saveButtonLabel), + ), ], ); } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f6367630..b029e7f5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1742,12 +1742,6 @@ abstract class AppLocalizations { /// **'Sign In'** String get anonymousLimitButton; - /// Title for the bottom sheet when a standard user hits a content limit. - /// - /// In en, this message translates to: - /// **'Unlock More Access'** - String get standardLimitTitle; - /// Body text for the bottom sheet when a standard user hits a content limit. /// /// In en, this message translates to: @@ -2599,6 +2593,414 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Remove Bookmark'** String get removeBookmarkActionLabel; + + /// Label for the action to report content. + /// + /// In en, this message translates to: + /// **'Report'** + String get reportActionLabel; + + /// Title for the report content bottom sheet. + /// + /// In en, this message translates to: + /// **'Report Content'** + String get reportContentTitle; + + /// Prompt asking the user to select a reason for reporting. + /// + /// In en, this message translates to: + /// **'Please select a reason for your report:'** + String get reportReasonSelectionPrompt; + + /// Label for the optional additional comments field in the report form. + /// + /// In en, this message translates to: + /// **'Additional Comments (Optional)'** + String get reportAdditionalCommentsLabel; + + /// Label for the button to submit a content report. + /// + /// In en, this message translates to: + /// **'Submit Report'** + String get reportSubmitButtonLabel; + + /// Snackbar message shown after a report is successfully submitted. + /// + /// In en, this message translates to: + /// **'Report submitted. Thank you for your feedback.'** + String get reportSuccessSnackbar; + + /// Snackbar message shown when submitting a report fails. + /// + /// In en, this message translates to: + /// **'Failed to submit report. Please try again.'** + String get reportFailureSnackbar; + + /// Title for the comments section or bottom sheet. + /// + /// In en, this message translates to: + /// **'Comments'** + String get commentsTitle; + + /// Label for the button to post a new comment. + /// + /// In en, this message translates to: + /// **'Post'** + String get commentPostButtonLabel; + + /// Hint text for the comment input field. + /// + /// In en, this message translates to: + /// **'Add a comment...'** + String get commentInputHint; + + /// Error message shown when a user tries to comment without selecting a reaction. + /// + /// In en, this message translates to: + /// **'Please select a reaction to post a comment.'** + String get commentRequiresReactionError; + + /// Tooltip or label for the 'Like' action. + /// + /// In en, this message translates to: + /// **'Like'** + String get likeActionLabel; + + /// Tooltip or label for the 'Dislike' action. + /// + /// In en, this message translates to: + /// **'Dislike'** + String get dislikeActionLabel; + + /// Tooltip or label for the 'Comment' action. + /// + /// In en, this message translates to: + /// **'Comment'** + String get commentActionLabel; + + /// Tooltip or label for the 'More' actions icon. + /// + /// In en, this message translates to: + /// **'More'** + String get moreActionLabel; + + /// Title for the in-app bottom sheet that prompts the user to rate the app. + /// + /// In en, this message translates to: + /// **'Enjoying the app?'** + String get rateAppPromptTitle; + + /// Body text for the in-app rating prompt bottom sheet. + /// + /// In en, this message translates to: + /// **'Your feedback helps us improve. Would you mind rating us?'** + String get rateAppPromptBody; + + /// Button text for a positive response to the rating prompt. + /// + /// In en, this message translates to: + /// **'It\'s Great!'** + String get rateAppPromptYesButton; + + /// Button text for a negative response to the rating prompt. + /// + /// In en, this message translates to: + /// **'Needs Work'** + String get rateAppPromptNoButton; + + /// Title for the bottom sheet that asks for detailed feedback after a negative rating prompt response. + /// + /// In en, this message translates to: + /// **'How can we improve?'** + String get feedbackPromptTitle; + + /// Feedback reason related to UI and design. + /// + /// In en, this message translates to: + /// **'UI / Design'** + String get feedbackPromptReasonUI; + + /// Feedback reason related to app performance. + /// + /// In en, this message translates to: + /// **'Performance / Speed'** + String get feedbackPromptReasonPerformance; + + /// Feedback reason related to the quality of news content. + /// + /// In en, this message translates to: + /// **'Content Quality'** + String get feedbackPromptReasonContent; + + /// Feedback reason for issues not listed. + /// + /// In en, this message translates to: + /// **'Other'** + String get feedbackPromptReasonOther; + + /// Button text to submit detailed feedback. + /// + /// In en, this message translates to: + /// **'Submit Feedback'** + String get feedbackPromptSubmitButton; + + /// First variation of the follow-up title for the 'Rate App' prompt after a user has given negative feedback. + /// + /// In en, this message translates to: + /// **'How are we doing now?'** + String get rateAppNegativeFollowUpTitle_1; + + /// Second variation of the follow-up title for the 'Rate App' prompt after a user has given negative feedback. + /// + /// In en, this message translates to: + /// **'Have we improved?'** + String get rateAppNegativeFollowUpTitle_2; + + /// First variation of the follow-up body text for the 'Rate App' prompt after a user has given negative feedback. + /// + /// In en, this message translates to: + /// **'We\'ve been working on your feedback. Would you reconsider your rating?'** + String get rateAppNegativeFollowUpBody_1; + + /// Second variation of the follow-up body text for the 'Rate App' prompt after a user has given negative feedback. + /// + /// In en, this message translates to: + /// **'We value your opinion. Let us know if things are better.'** + String get rateAppNegativeFollowUpBody_2; + + /// Message displayed when there are no comments on a headline. + /// + /// In en, this message translates to: + /// **'No comments yet.'** + String get noCommentsYet; + + /// Hint text for the comment input field when the user has not yet reacted. + /// + /// In en, this message translates to: + /// **'React to add a comment'** + String get commentInputNoReactionHint; + + /// Report reason for factually incorrect content. + /// + /// In en, this message translates to: + /// **'Misinformation or Fake News'** + String get headlineReportReasonMisinformation; + + /// Report reason for a clickbait headline. + /// + /// In en, this message translates to: + /// **'Clickbait or Misleading Title'** + String get headlineReportReasonClickbait; + + /// Report reason for offensive content. + /// + /// In en, this message translates to: + /// **'Offensive or Hate Speech'** + String get headlineReportReasonOffensive; + + /// Report reason for spam or fraudulent content. + /// + /// In en, this message translates to: + /// **'Spam or Scam'** + String get headlineReportReasonSpam; + + /// Report reason for a non-working article URL. + /// + /// In en, this message translates to: + /// **'Broken Link'** + String get headlineReportReasonBrokenLink; + + /// Report reason for content that requires a subscription. + /// + /// In en, this message translates to: + /// **'Paywalled Content'** + String get headlineReportReasonPaywalled; + + /// Report reason for a source with poor content. + /// + /// In en, this message translates to: + /// **'Low-Quality Journalism'** + String get sourceReportReasonLowQuality; + + /// Report reason for a source with a bad user experience due to ads. + /// + /// In en, this message translates to: + /// **'Excessive Ads or Popups'** + String get sourceReportReasonHighAdDensity; + + /// Report reason for a source that often requires a subscription. + /// + /// In en, this message translates to: + /// **'Frequent Paywalls'** + String get sourceReportReasonFrequentPaywalls; + + /// Report reason for a source pretending to be another entity. + /// + /// In en, this message translates to: + /// **'Impersonation'** + String get sourceReportReasonImpersonation; + + /// Report reason for a source pretending to be another entity. + /// + /// In en, this message translates to: + /// **'Misinformation'** + String get sourceReportReasonMisinformation; + + /// Report reason for a comment that is spam. + /// + /// In en, this message translates to: + /// **'Spam or Advertising'** + String get commentReportReasonSpam; + + /// Report reason for a comment that is abusive. + /// + /// In en, this message translates to: + /// **'Harassment or Bullying'** + String get commentReportReasonHarassment; + + /// Report reason for a comment containing hate speech. + /// + /// In en, this message translates to: + /// **'Hate Speech'** + String get commentReportReasonHateSpeech; + + /// Generic title for when a user hits a content limit. + /// + /// In en, this message translates to: + /// **'Limit Reached'** + String get limitReachedTitle; + + /// Button text that navigates the user to a page where they can manage their content (e.g., unfollow items). + /// + /// In en, this message translates to: + /// **'Manage My Content'** + String get manageMyContentButton; + + /// Button text prompting the user to upgrade their subscription plan. + /// + /// In en, this message translates to: + /// **'Upgrade'** + String get upgradeButton; + + /// Button text prompting an anonymous user to create an account. + /// + /// In en, this message translates to: + /// **'Create Account'** + String get createAccountButton; + + /// A simple acknowledgement button. + /// + /// In en, this message translates to: + /// **'Got It'** + String get gotItButton; + + /// Title for the page or bottom sheet that displays comments for a headline. + /// + /// In en, this message translates to: + /// **'Comments'** + String get commentsPageTitle; + + /// Label for a button that shows the number of comments and opens the comments view. + /// + /// In en, this message translates to: + /// **'{count,plural, =1{1 Comment}other{{count} Comments}}'** + String commentsCount(int count); + + /// Title for the bottom sheet when a guest user tries to perform an action reserved for authenticated users (e.g., comment, react). + /// + /// In en, this message translates to: + /// **'Account Required'** + String get limitReachedGuestUserTitle; + + /// Body text for the bottom sheet when a guest user tries to perform an action reserved for authenticated users. + /// + /// In en, this message translates to: + /// **'Please create a free account or sign in to engage with the community and use this feature.'** + String get limitReachedGuestUserBody; + + /// Body text when a user tries to follow more items (topics, sources, countries) than their plan allows. + /// + /// In en, this message translates to: + /// **'You have reached your limit for followed items. To follow more, please review your existing followed content.'** + String get limitReachedBodyFollow; + + /// Body text when a user tries to save more articles than their plan allows. + /// + /// In en, this message translates to: + /// **'You have reached your limit for saved articles. To save more, please review your existing saved articles.'** + String get limitReachedBodySave; + + /// Body text when a user tries to save more filters than their plan allows. + /// + /// In en, this message translates to: + /// **'You have reached your limit for saved filters. To create new ones, please review your existing filters.'** + String get limitReachedBodySaveFilters; + + /// Body text when a user tries to pin more filters than their plan allows. + /// + /// In en, this message translates to: + /// **'You have reached your limit for pinned filters. To pin a new one, please un-pin an existing filter.'** + String get limitReachedBodyPinFilters; + + /// Body text when a user tries to subscribe to more notifications than their plan allows. + /// + /// In en, this message translates to: + /// **'You have reached your limit for notification subscriptions. To subscribe to new alerts, please review your existing subscriptions.'** + String get limitReachedBodySubscribeToNotifications; + + /// Body text when a user tries to post more comments than their daily limit allows. + /// + /// In en, this message translates to: + /// **'You have reached your daily limit for posting comments. Please try again tomorrow.'** + String get limitReachedBodyComments; + + /// Body text when a user tries to react more than their daily limit allows. + /// + /// In en, this message translates to: + /// **'You have reached your daily limit for reactions. Please try again tomorrow.'** + String get limitReachedBodyReactions; + + /// Body text when a user tries to submit more reports than their daily limit allows. + /// + /// In en, this message translates to: + /// **'You have reached your daily limit for submitting reports. Please try again tomorrow.'** + String get limitReachedBodyReports; + + /// Label for the button to update an existing comment. + /// + /// In en, this message translates to: + /// **'Update'** + String get commentEditButtonLabel; + + /// Snackbar message shown when posting a comment fails. + /// + /// In en, this message translates to: + /// **'Failed to post comment. Please try again.'** + String get commentPostFailureSnackbar; + + /// Snackbar message shown when updating a comment fails. + /// + /// In en, this message translates to: + /// **'Failed to update comment. Please try again.'** + String get commentUpdateFailureSnackbar; + + /// Hint text for the comment input field when the user is a guest. + /// + /// In en, this message translates to: + /// **'Sign in to join the conversation'** + String get commentInputDisabledHint; + + /// Hint text for the comment input field when the user has already posted a comment. + /// + /// In en, this message translates to: + /// **'You have already commented on this headline.'** + String get commentInputExistingHint; + + /// Display name for a commenter when their full user object is not available, showing a partial ID. + /// + /// In en, this message translates to: + /// **'User {id}'** + String commenterName(String id); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index e5592db9..5550d1be 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -915,9 +915,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get anonymousLimitButton => 'تسجيل الدخول'; - @override - String get standardLimitTitle => 'افتح الوصول غير المحدود'; - @override String get standardLimitBody => 'لقد وصلت إلى الحد الأقصى للباقة المجانية. قم بالترقية لحفظ ومتابعة المزيد.'; @@ -1367,4 +1364,238 @@ class AppLocalizationsAr extends AppLocalizations { @override String get removeBookmarkActionLabel => 'إزالة من المحفوظات'; + + @override + String get reportActionLabel => 'إبلاغ'; + + @override + String get reportContentTitle => 'الإبلاغ عن محتوى'; + + @override + String get reportReasonSelectionPrompt => 'الرجاء تحديد سبب الإبلاغ:'; + + @override + String get reportAdditionalCommentsLabel => 'تعليقات إضافية (اختياري)'; + + @override + String get reportSubmitButtonLabel => 'إرسال البلاغ'; + + @override + String get reportSuccessSnackbar => 'تم إرسال البلاغ. شكراً لملاحظاتك.'; + + @override + String get reportFailureSnackbar => + 'فشل إرسال البلاغ. الرجاء المحاولة مرة أخرى.'; + + @override + String get commentsTitle => 'التعليقات'; + + @override + String get commentPostButtonLabel => 'نشر'; + + @override + String get commentInputHint => 'أضف تعليقاً...'; + + @override + String get commentRequiresReactionError => 'الرجاء تحديد تفاعل لنشر تعليق.'; + + @override + String get likeActionLabel => 'إعجاب'; + + @override + String get dislikeActionLabel => 'عدم إعجاب'; + + @override + String get commentActionLabel => 'تعليق'; + + @override + String get moreActionLabel => 'المزيد'; + + @override + String get rateAppPromptTitle => 'هل تستمتع بالتطبيق؟'; + + @override + String get rateAppPromptBody => + 'تساعدنا ملاحظاتك على التحسين. هل تمانع في تقييمنا؟'; + + @override + String get rateAppPromptYesButton => 'التطبيق رائع!'; + + @override + String get rateAppPromptNoButton => 'يحتاج لتحسين'; + + @override + String get feedbackPromptTitle => 'كيف يمكننا التحسين؟'; + + @override + String get feedbackPromptReasonUI => 'واجهة المستخدم / التصميم'; + + @override + String get feedbackPromptReasonPerformance => 'الأداء / السرعة'; + + @override + String get feedbackPromptReasonContent => 'جودة المحتوى'; + + @override + String get feedbackPromptReasonOther => 'أخرى'; + + @override + String get feedbackPromptSubmitButton => 'إرسال الملاحظات'; + + @override + String get rateAppNegativeFollowUpTitle_1 => 'كيف حالنا الآن؟'; + + @override + String get rateAppNegativeFollowUpTitle_2 => 'هل تحسن الأداء؟'; + + @override + String get rateAppNegativeFollowUpBody_1 => + 'لقد عملنا على ملاحظاتك. هل تعيد النظر في تقييمك؟'; + + @override + String get rateAppNegativeFollowUpBody_2 => + 'نحن نقدر رأيك. أخبرنا إذا كانت الأمور أفضل.'; + + @override + String get noCommentsYet => 'لا توجد تعليقات حتى الآن.'; + + @override + String get commentInputNoReactionHint => 'تفاعل لإضافة تعليق'; + + @override + String get headlineReportReasonMisinformation => + 'معلومات مضللة أو أخبار كاذبة'; + + @override + String get headlineReportReasonClickbait => 'عنوان مضلل أو طعم نقر'; + + @override + String get headlineReportReasonOffensive => 'محتوى مسيء أو خطاب كراهية'; + + @override + String get headlineReportReasonSpam => 'بريد عشوائي أو احتيال'; + + @override + String get headlineReportReasonBrokenLink => 'رابط معطل'; + + @override + String get headlineReportReasonPaywalled => 'محتوى مدفوع'; + + @override + String get sourceReportReasonLowQuality => 'صحافة منخفضة الجودة'; + + @override + String get sourceReportReasonHighAdDensity => 'إعلانات أو نوافذ منبثقة مفرطة'; + + @override + String get sourceReportReasonFrequentPaywalls => 'جدران دفع متكررة'; + + @override + String get sourceReportReasonImpersonation => 'انتحال شخصية'; + + @override + String get sourceReportReasonMisinformation => 'معلومات مضللة أو أخبار كاذبة'; + + @override + String get commentReportReasonSpam => 'إعلانات'; + + @override + String get commentReportReasonHarassment => 'تحرش أو تنمر'; + + @override + String get commentReportReasonHateSpeech => 'خطاب كراهية'; + + @override + String get limitReachedTitle => 'تم الوصول إلى الحد الأقصى'; + + @override + String get manageMyContentButton => 'إدارة المحتوى الخاص بي'; + + @override + String get upgradeButton => 'ترقية'; + + @override + String get createAccountButton => 'إنشاء حساب'; + + @override + String get gotItButton => 'حسنًا'; + + @override + String get commentsPageTitle => 'التعليقات'; + + @override + String commentsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count تعليق', + many: '$count تعليقًا', + few: '$count تعليقات', + two: 'تعليقان', + one: 'تعليق واحد', + zero: '0 تعليقات', + ); + return '$_temp0'; + } + + @override + String get limitReachedGuestUserTitle => 'الحساب مطلوب'; + + @override + String get limitReachedGuestUserBody => + 'يرجى إنشاء حساب مجاني أو تسجيل الدخول للتفاعل مع المجتمع واستخدام هذه الميزة.'; + + @override + String get limitReachedBodyFollow => + 'لقد وصلت إلى الحد الأقصى للعناصر المتابعة. لمتابعة المزيد، يرجى مراجعة المحتوى المتابع الحالي.'; + + @override + String get limitReachedBodySave => + 'لقد وصلت إلى الحد الأقصى للمقالات المحفوظة. لحفظ المزيد، يرجى مراجعة مقالاتك المحفوظة الحالية.'; + + @override + String get limitReachedBodySaveFilters => + 'لقد وصلت إلى الحد الأقصى للفلاتر المحفوظة. لإنشاء فلاتر جديدة، يرجى مراجعة الفلاتر الحالية.'; + + @override + String get limitReachedBodyPinFilters => + 'لقد وصلت إلى الحد الأقصى للفلاتر المثبتة. لتثبيت فلتر جديد، يرجى إلغاء تثبيت فلتر حالي.'; + + @override + String get limitReachedBodySubscribeToNotifications => + 'لقد وصلت إلى الحد الأقصى لاشتراكات الإشعارات. للاشتراك في تنبيهات جديدة، يرجى مراجعة اشتراكاتك الحالية.'; + + @override + String get limitReachedBodyComments => + 'لقد وصلت إلى الحد اليومي لنشر التعليقات. يرجى المحاولة مرة أخرى غدًا.'; + + @override + String get limitReachedBodyReactions => + 'لقد وصلت إلى الحد اليومي للتفاعلات. يرجى المحاولة مرة أخرى غدًا.'; + + @override + String get limitReachedBodyReports => + 'لقد وصلت إلى الحد اليومي لتقديم البلاغات. يرجى المحاولة مرة أخرى غدًا.'; + + @override + String get commentEditButtonLabel => 'تحديث'; + + @override + String get commentPostFailureSnackbar => + 'فشل نشر التعليق. يرجى المحاولة مرة أخرى.'; + + @override + String get commentUpdateFailureSnackbar => + 'فشل تحديث التعليق. يرجى المحاولة مرة أخرى.'; + + @override + String get commentInputDisabledHint => 'سجل الدخول للانضمام إلى المحادثة'; + + @override + String get commentInputExistingHint => 'لقد علقت بالفعل على هذا العنوان.'; + + @override + String commenterName(String id) { + return 'مستخدم $id'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df1d90bd..22388012 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -916,9 +916,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get anonymousLimitButton => 'Sign In'; - @override - String get standardLimitTitle => 'Unlock More Access'; - @override String get standardLimitBody => 'You\'ve reached your limit for the free plan. Upgrade to save and follow more.'; @@ -1371,4 +1368,238 @@ class AppLocalizationsEn extends AppLocalizations { @override String get removeBookmarkActionLabel => 'Remove Bookmark'; + + @override + String get reportActionLabel => 'Report'; + + @override + String get reportContentTitle => 'Report Content'; + + @override + String get reportReasonSelectionPrompt => + 'Please select a reason for your report:'; + + @override + String get reportAdditionalCommentsLabel => 'Additional Comments (Optional)'; + + @override + String get reportSubmitButtonLabel => 'Submit Report'; + + @override + String get reportSuccessSnackbar => + 'Report submitted. Thank you for your feedback.'; + + @override + String get reportFailureSnackbar => + 'Failed to submit report. Please try again.'; + + @override + String get commentsTitle => 'Comments'; + + @override + String get commentPostButtonLabel => 'Post'; + + @override + String get commentInputHint => 'Add a comment...'; + + @override + String get commentRequiresReactionError => + 'Please select a reaction to post a comment.'; + + @override + String get likeActionLabel => 'Like'; + + @override + String get dislikeActionLabel => 'Dislike'; + + @override + String get commentActionLabel => 'Comment'; + + @override + String get moreActionLabel => 'More'; + + @override + String get rateAppPromptTitle => 'Enjoying the app?'; + + @override + String get rateAppPromptBody => + 'Your feedback helps us improve. Would you mind rating us?'; + + @override + String get rateAppPromptYesButton => 'It\'s Great!'; + + @override + String get rateAppPromptNoButton => 'Needs Work'; + + @override + String get feedbackPromptTitle => 'How can we improve?'; + + @override + String get feedbackPromptReasonUI => 'UI / Design'; + + @override + String get feedbackPromptReasonPerformance => 'Performance / Speed'; + + @override + String get feedbackPromptReasonContent => 'Content Quality'; + + @override + String get feedbackPromptReasonOther => 'Other'; + + @override + String get feedbackPromptSubmitButton => 'Submit Feedback'; + + @override + String get rateAppNegativeFollowUpTitle_1 => 'How are we doing now?'; + + @override + String get rateAppNegativeFollowUpTitle_2 => 'Have we improved?'; + + @override + String get rateAppNegativeFollowUpBody_1 => + 'We\'ve been working on your feedback. Would you reconsider your rating?'; + + @override + String get rateAppNegativeFollowUpBody_2 => + 'We value your opinion. Let us know if things are better.'; + + @override + String get noCommentsYet => 'No comments yet.'; + + @override + String get commentInputNoReactionHint => 'React to add a comment'; + + @override + String get headlineReportReasonMisinformation => + 'Misinformation or Fake News'; + + @override + String get headlineReportReasonClickbait => 'Clickbait or Misleading Title'; + + @override + String get headlineReportReasonOffensive => 'Offensive or Hate Speech'; + + @override + String get headlineReportReasonSpam => 'Spam or Scam'; + + @override + String get headlineReportReasonBrokenLink => 'Broken Link'; + + @override + String get headlineReportReasonPaywalled => 'Paywalled Content'; + + @override + String get sourceReportReasonLowQuality => 'Low-Quality Journalism'; + + @override + String get sourceReportReasonHighAdDensity => 'Excessive Ads or Popups'; + + @override + String get sourceReportReasonFrequentPaywalls => 'Frequent Paywalls'; + + @override + String get sourceReportReasonImpersonation => 'Impersonation'; + + @override + String get sourceReportReasonMisinformation => 'Misinformation'; + + @override + String get commentReportReasonSpam => 'Spam or Advertising'; + + @override + String get commentReportReasonHarassment => 'Harassment or Bullying'; + + @override + String get commentReportReasonHateSpeech => 'Hate Speech'; + + @override + String get limitReachedTitle => 'Limit Reached'; + + @override + String get manageMyContentButton => 'Manage My Content'; + + @override + String get upgradeButton => 'Upgrade'; + + @override + String get createAccountButton => 'Create Account'; + + @override + String get gotItButton => 'Got It'; + + @override + String get commentsPageTitle => 'Comments'; + + @override + String commentsCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Comments', + one: '1 Comment', + ); + return '$_temp0'; + } + + @override + String get limitReachedGuestUserTitle => 'Account Required'; + + @override + String get limitReachedGuestUserBody => + 'Please create a free account or sign in to engage with the community and use this feature.'; + + @override + String get limitReachedBodyFollow => + 'You have reached your limit for followed items. To follow more, please review your existing followed content.'; + + @override + String get limitReachedBodySave => + 'You have reached your limit for saved articles. To save more, please review your existing saved articles.'; + + @override + String get limitReachedBodySaveFilters => + 'You have reached your limit for saved filters. To create new ones, please review your existing filters.'; + + @override + String get limitReachedBodyPinFilters => + 'You have reached your limit for pinned filters. To pin a new one, please un-pin an existing filter.'; + + @override + String get limitReachedBodySubscribeToNotifications => + 'You have reached your limit for notification subscriptions. To subscribe to new alerts, please review your existing subscriptions.'; + + @override + String get limitReachedBodyComments => + 'You have reached your daily limit for posting comments. Please try again tomorrow.'; + + @override + String get limitReachedBodyReactions => + 'You have reached your daily limit for reactions. Please try again tomorrow.'; + + @override + String get limitReachedBodyReports => + 'You have reached your daily limit for submitting reports. Please try again tomorrow.'; + + @override + String get commentEditButtonLabel => 'Update'; + + @override + String get commentPostFailureSnackbar => + 'Failed to post comment. Please try again.'; + + @override + String get commentUpdateFailureSnackbar => + 'Failed to update comment. Please try again.'; + + @override + String get commentInputDisabledHint => 'Sign in to join the conversation'; + + @override + String get commentInputExistingHint => + 'You have already commented on this headline.'; + + @override + String commenterName(String id) { + return 'User $id'; + } } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 68467cf9..32020862 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1202,10 +1202,6 @@ "@anonymousLimitButton": { "description": "Button text for the bottom sheet when an anonymous user hits a content limit." }, - "standardLimitTitle": "افتح الوصول غير المحدود", - "@standardLimitTitle": { - "description": "Title for the bottom sheet when a standard user hits a content limit." - }, "standardLimitBody": "لقد وصلت إلى الحد الأقصى للباقة المجانية. قم بالترقية لحفظ ومتابعة المزيد.", "@standardLimitBody": { "description": "Body text for the bottom sheet when a standard user hits a content limit." @@ -1773,5 +1769,288 @@ "removeBookmarkActionLabel": "إزالة من المحفوظات", "@removeBookmarkActionLabel": { "description": "Label for the action to remove a headline from bookmarks." + }, + "reportActionLabel": "إبلاغ", + "@reportActionLabel": { + "description": "تسمية لإجراء الإبلاغ عن محتوى." + }, + "reportContentTitle": "الإبلاغ عن محتوى", + "@reportContentTitle": { + "description": "عنوان نافذة الإبلاغ عن محتوى." + }, + "reportReasonSelectionPrompt": "الرجاء تحديد سبب الإبلاغ:", + "@reportReasonSelectionPrompt": { + "description": "نص يطلب من المستخدم تحديد سبب الإبلاغ." + }, + "reportAdditionalCommentsLabel": "تعليقات إضافية (اختياري)", + "@reportAdditionalCommentsLabel": { + "description": "تسمية حقل التعليقات الإضافية الاختياري في نموذج الإبلاغ." + }, + "reportSubmitButtonLabel": "إرسال البلاغ", + "@reportSubmitButtonLabel": { + "description": "تسمية زر إرسال بلاغ المحتوى." + }, + "reportSuccessSnackbar": "تم إرسال البلاغ. شكراً لملاحظاتك.", + "@reportSuccessSnackbar": { + "description": "رسالة تظهر بعد إرسال البلاغ بنجاح." + }, + "reportFailureSnackbar": "فشل إرسال البلاغ. الرجاء المحاولة مرة أخرى.", + "@reportFailureSnackbar": { + "description": "رسالة تظهر عند فشل إرسال البلاغ." + }, + "commentsTitle": "التعليقات", + "@commentsTitle": { + "description": "عنوان قسم أو نافذة التعليقات." + }, + "commentPostButtonLabel": "نشر", + "@commentPostButtonLabel": { + "description": "تسمية زر نشر تعليق جديد." + }, + "commentInputHint": "أضف تعليقاً...", + "@commentInputHint": { + "description": "نص تلميحي لحقل إدخال التعليق." + }, + "commentRequiresReactionError": "الرجاء تحديد تفاعل لنشر تعليق.", + "@commentRequiresReactionError": { + "description": "رسالة خطأ تظهر عندما يحاول المستخدم التعليق بدون تحديد تفاعل." + }, + "likeActionLabel": "إعجاب", + "@likeActionLabel": { + "description": "تلميح أو تسمية لإجراء 'الإعجاب'." + }, + "dislikeActionLabel": "عدم إعجاب", + "@dislikeActionLabel": { + "description": "تلميح أو تسمية لإجراء 'عدم الإعجاب'." + }, + "commentActionLabel": "تعليق", + "@commentActionLabel": { + "description": "تلميح أو تسمية لإجراء 'التعليق'." + }, + "moreActionLabel": "المزيد", + "@moreActionLabel": { + "description": "تلميح أو تسمية أيقونة 'المزيد' من الإجراءات." + }, + "rateAppPromptTitle": "هل تستمتع بالتطبيق؟", + "@rateAppPromptTitle": { + "description": "عنوان النافذة التي تطلب من المستخدم تقييم التطبيق." + }, + "rateAppPromptBody": "تساعدنا ملاحظاتك على التحسين. هل تمانع في تقييمنا؟", + "@rateAppPromptBody": { + "description": "نص النافذة التي تطلب من المستخدم تقييم التطبيق." + }, + "rateAppPromptYesButton": "التطبيق رائع!", + "@rateAppPromptYesButton": { + "description": "نص زر الاستجابة الإيجابية لطلب التقييم." + }, + "rateAppPromptNoButton": "يحتاج لتحسين", + "@rateAppPromptNoButton": { + "description": "نص زر الاستجابة السلبية لطلب التقييم." + }, + "feedbackPromptTitle": "كيف يمكننا التحسين؟", + "@feedbackPromptTitle": { + "description": "عنوان النافذة التي تطلب ملاحظات تفصيلية بعد استجابة سلبية لطلب التقييم." + }, + "feedbackPromptReasonUI": "واجهة المستخدم / التصميم", + "@feedbackPromptReasonUI": { + "description": "سبب الملاحظات المتعلق بواجهة المستخدم والتصميم." + }, + "feedbackPromptReasonPerformance": "الأداء / السرعة", + "@feedbackPromptReasonPerformance": { + "description": "سبب الملاحظات المتعلق بأداء التطبيق." + }, + "feedbackPromptReasonContent": "جودة المحتوى", + "@feedbackPromptReasonContent": { + "description": "سبب الملاحظات المتعلق بجودة محتوى الأخبار." + }, + "feedbackPromptReasonOther": "أخرى", + "@feedbackPromptReasonOther": { + "description": "سبب الملاحظات للمشكلات غير المدرجة." + }, + "feedbackPromptSubmitButton": "إرسال الملاحظات", + "@feedbackPromptSubmitButton": { + "description": "نص زر إرسال الملاحظات التفصيلية." + }, + "rateAppNegativeFollowUpTitle_1": "كيف حالنا الآن؟", + "@rateAppNegativeFollowUpTitle_1": { + "description": "الصيغة الأولى لعنوان المتابعة لطلب 'تقييم التطبيق' بعد أن يقدم المستخدم ملاحظات سلبية." + }, + "rateAppNegativeFollowUpTitle_2": "هل تحسن الأداء؟", + "@rateAppNegativeFollowUpTitle_2": { + "description": "الصيغة الثانية لعنوان المتابعة لطلب 'تقييم التطبيق' بعد أن يقدم المستخدم ملاحظات سلبية." + }, + "rateAppNegativeFollowUpBody_1": "لقد عملنا على ملاحظاتك. هل تعيد النظر في تقييمك؟", + "@rateAppNegativeFollowUpBody_1": { + "description": "الصيغة الأولى لنص المتابعة لطلب 'تقييم التطبيق' بعد أن يقدم المستخدم ملاحظات سلبية." + }, + "rateAppNegativeFollowUpBody_2": "نحن نقدر رأيك. أخبرنا إذا كانت الأمور أفضل.", + "@rateAppNegativeFollowUpBody_2": { + "description": "الصيغة الثانية لنص المتابعة لطلب 'تقييم التطبيق' بعد أن يقدم المستخدم ملاحظات سلبية." + }, + "noCommentsYet": "لا توجد تعليقات حتى الآن.", + "@noCommentsYet": { + "description": "Message displayed when there are no comments on a headline." + }, + "commentInputNoReactionHint": "تفاعل لإضافة تعليق", + "@commentInputNoReactionHint": { + "description": "Hint text for the comment input field when the user has not yet reacted." + }, + "headlineReportReasonMisinformation": "معلومات مضللة أو أخبار كاذبة", + "@headlineReportReasonMisinformation": { + "description": "Report reason for factually incorrect content." + }, + "headlineReportReasonClickbait": "عنوان مضلل أو طعم نقر", + "@headlineReportReasonClickbait": { + "description": "Report reason for a clickbait headline." + }, + "headlineReportReasonOffensive": "محتوى مسيء أو خطاب كراهية", + "@headlineReportReasonOffensive": { + "description": "Report reason for offensive content." + }, + "headlineReportReasonSpam": "بريد عشوائي أو احتيال", + "@headlineReportReasonSpam": { + "description": "Report reason for spam or fraudulent content." + }, + "headlineReportReasonBrokenLink": "رابط معطل", + "@headlineReportReasonBrokenLink": { + "description": "Report reason for a non-working article URL." + }, + "headlineReportReasonPaywalled": "محتوى مدفوع", + "@headlineReportReasonPaywalled": { + "description": "Report reason for content that requires a subscription." + }, + "sourceReportReasonLowQuality": "صحافة منخفضة الجودة", + "@sourceReportReasonLowQuality": { + "description": "Report reason for a source with poor content." + }, + "sourceReportReasonHighAdDensity": "إعلانات أو نوافذ منبثقة مفرطة", + "@sourceReportReasonHighAdDensity": { + "description": "Report reason for a source with a bad user experience due to ads." + }, + "sourceReportReasonFrequentPaywalls": "جدران دفع متكررة", + "@sourceReportReasonFrequentPaywalls": { + "description": "Report reason for a source that often requires a subscription." + }, + "sourceReportReasonImpersonation": "انتحال شخصية", + "@sourceReportReasonImpersonation": { + "description": "Report reason for a source pretending to be another entity." + }, + "sourceReportReasonMisinformation": "معلومات مضللة أو أخبار كاذبة", + "@sourceReportReasonMisinformation": { + "description": "Report reason for a source pretending to be another entity." + }, + "commentReportReasonSpam": "إعلانات", + "@commentReportReasonSpam": { + "description": "Report reason for a comment that is spam." + }, + "commentReportReasonHarassment": "تحرش أو تنمر", + "@commentReportReasonHarassment": { + "description": "Report reason for a comment that is abusive." + }, + "commentReportReasonHateSpeech": "خطاب كراهية", + "@commentReportReasonHateSpeech": { + "description": "Report reason for a comment containing hate speech." + }, + "limitReachedTitle": "تم الوصول إلى الحد الأقصى", + "@limitReachedTitle": { + "description": "Generic title for when a user hits a content limit." + }, + "manageMyContentButton": "إدارة المحتوى الخاص بي", + "@manageMyContentButton": { + "description": "Button text that navigates the user to a page where they can manage their content (e.g., unfollow items)." + }, + "upgradeButton": "ترقية", + "@upgradeButton": { + "description": "Button text prompting the user to upgrade their subscription plan." + }, + "createAccountButton": "إنشاء حساب", + "@createAccountButton": { + "description": "Button text prompting an anonymous user to create an account." + }, + "gotItButton": "حسنًا", + "@gotItButton": { + "description": "A simple acknowledgement button." + }, + "commentsPageTitle": "التعليقات", + "@commentsPageTitle": { + "description": "عنوان الصفحة أو الورقة السفلية التي تعرض التعليقات على عنوان رئيسي." + }, + "commentsCount": "{count,plural, zero{0 تعليقات} one{تعليق واحد} two{تعليقان} few{{count} تعليقات} many{{count} تعليقًا} other{{count} تعليق}}", + "@commentsCount": { + "description": "تسمية زر يعرض عدد التعليقات ويفتح عرض التعليقات.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "limitReachedGuestUserTitle": "الحساب مطلوب", + "@limitReachedGuestUserTitle": { + "description": "عنوان للنافذة السفلية عندما يحاول مستخدم زائر القيام بإجراء محجوز للمستخدمين المصادق عليهم (مثل التعليق، التفاعل)." + }, + "limitReachedGuestUserBody": "يرجى إنشاء حساب مجاني أو تسجيل الدخول للتفاعل مع المجتمع واستخدام هذه الميزة.", + "@limitReachedGuestUserBody": { + "description": "نص للنافذة السفلية عندما يحاول مستخدم زائر القيام بإجراء محجوز للمستخدمين المصادق عليهم." + }, + "limitReachedBodyFollow": "لقد وصلت إلى الحد الأقصى للعناصر المتابعة. لمتابعة المزيد، يرجى مراجعة المحتوى المتابع الحالي.", + "@limitReachedBodyFollow": { + "description": "نص يظهر عندما يحاول المستخدم متابعة عناصر (مواضيع، مصادر، دول) أكثر مما تسمح به خطته." + }, + "limitReachedBodySave": "لقد وصلت إلى الحد الأقصى للمقالات المحفوظة. لحفظ المزيد، يرجى مراجعة مقالاتك المحفوظة الحالية.", + "@limitReachedBodySave": { + "description": "نص يظهر عندما يحاول المستخدم حفظ مقالات أكثر مما تسمح به خطته." + }, + "limitReachedBodySaveFilters": "لقد وصلت إلى الحد الأقصى للفلاتر المحفوظة. لإنشاء فلاتر جديدة، يرجى مراجعة الفلاتر الحالية.", + "@limitReachedBodySaveFilters": { + "description": "نص يظهر عندما يحاول المستخدم حفظ فلاتر أكثر مما تسمح به خطته." + }, + "limitReachedBodyPinFilters": "لقد وصلت إلى الحد الأقصى للفلاتر المثبتة. لتثبيت فلتر جديد، يرجى إلغاء تثبيت فلتر حالي.", + "@limitReachedBodyPinFilters": { + "description": "نص يظهر عندما يحاول المستخدم تثبيت فلاتر أكثر مما تسمح به خطته." + }, + "limitReachedBodySubscribeToNotifications": "لقد وصلت إلى الحد الأقصى لاشتراكات الإشعارات. للاشتراك في تنبيهات جديدة، يرجى مراجعة اشتراكاتك الحالية.", + "@limitReachedBodySubscribeToNotifications": { + "description": "نص يظهر عندما يحاول المستخدم الاشتراك في إشعارات أكثر مما تسمح به خطته." + }, + "limitReachedBodyComments": "لقد وصلت إلى الحد اليومي لنشر التعليقات. يرجى المحاولة مرة أخرى غدًا.", + "@limitReachedBodyComments": { + "description": "نص يظهر عندما يحاول المستخدم نشر تعليقات أكثر من حده اليومي." + }, + "limitReachedBodyReactions": "لقد وصلت إلى الحد اليومي للتفاعلات. يرجى المحاولة مرة أخرى غدًا.", + "@limitReachedBodyReactions": { + "description": "نص يظهر عندما يحاول المستخدم التفاعل أكثر من حده اليومي." + }, + "limitReachedBodyReports": "لقد وصلت إلى الحد اليومي لتقديم البلاغات. يرجى المحاولة مرة أخرى غدًا.", + "@limitReachedBodyReports": { + "description": "نص يظهر عندما يحاول المستخدم تقديم بلاغات أكثر من حده اليومي." + }, + "commentEditButtonLabel": "تحديث", + "@commentEditButtonLabel": { + "description": "Label for the button to update an existing comment." + }, + "commentPostFailureSnackbar": "فشل نشر التعليق. يرجى المحاولة مرة أخرى.", + "@commentPostFailureSnackbar": { + "description": "Snackbar message shown when posting a comment fails." + }, + "commentUpdateFailureSnackbar": "فشل تحديث التعليق. يرجى المحاولة مرة أخرى.", + "@commentUpdateFailureSnackbar": { + "description": "Snackbar message shown when updating a comment fails." + }, + "commentInputDisabledHint": "سجل الدخول للانضمام إلى المحادثة", + "@commentInputDisabledHint": { + "description": "Hint text for the comment input field when the user is a guest." + }, + "commentInputExistingHint": "لقد علقت بالفعل على هذا العنوان.", + "@commentInputExistingHint": { + "description": "Hint text for the comment input field when the user has already posted a comment." + }, + "commenterName": "مستخدم {id}", + "@commenterName": { + "description": "Display name for a commenter when their full user object is not available, showing a partial ID.", + "placeholders": { + "id": { + "type": "String", + "example": "1a2b" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 52732df7..4d10dc4d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1202,10 +1202,6 @@ "@anonymousLimitButton": { "description": "Button text for the bottom sheet when an anonymous user hits a content limit." }, - "standardLimitTitle": "Unlock More Access", - "@standardLimitTitle": { - "description": "Title for the bottom sheet when a standard user hits a content limit." - }, "standardLimitBody": "You've reached your limit for the free plan. Upgrade to save and follow more.", "@standardLimitBody": { "description": "Body text for the bottom sheet when a standard user hits a content limit." @@ -1773,5 +1769,288 @@ "removeBookmarkActionLabel": "Remove Bookmark", "@removeBookmarkActionLabel": { "description": "Label for the action to remove a headline from bookmarks." + }, + "reportActionLabel": "Report", + "@reportActionLabel": { + "description": "Label for the action to report content." + }, + "reportContentTitle": "Report Content", + "@reportContentTitle": { + "description": "Title for the report content bottom sheet." + }, + "reportReasonSelectionPrompt": "Please select a reason for your report:", + "@reportReasonSelectionPrompt": { + "description": "Prompt asking the user to select a reason for reporting." + }, + "reportAdditionalCommentsLabel": "Additional Comments (Optional)", + "@reportAdditionalCommentsLabel": { + "description": "Label for the optional additional comments field in the report form." + }, + "reportSubmitButtonLabel": "Submit Report", + "@reportSubmitButtonLabel": { + "description": "Label for the button to submit a content report." + }, + "reportSuccessSnackbar": "Report submitted. Thank you for your feedback.", + "@reportSuccessSnackbar": { + "description": "Snackbar message shown after a report is successfully submitted." + }, + "reportFailureSnackbar": "Failed to submit report. Please try again.", + "@reportFailureSnackbar": { + "description": "Snackbar message shown when submitting a report fails." + }, + "commentsTitle": "Comments", + "@commentsTitle": { + "description": "Title for the comments section or bottom sheet." + }, + "commentPostButtonLabel": "Post", + "@commentPostButtonLabel": { + "description": "Label for the button to post a new comment." + }, + "commentInputHint": "Add a comment...", + "@commentInputHint": { + "description": "Hint text for the comment input field." + }, + "commentRequiresReactionError": "Please select a reaction to post a comment.", + "@commentRequiresReactionError": { + "description": "Error message shown when a user tries to comment without selecting a reaction." + }, + "likeActionLabel": "Like", + "@likeActionLabel": { + "description": "Tooltip or label for the 'Like' action." + }, + "dislikeActionLabel": "Dislike", + "@dislikeActionLabel": { + "description": "Tooltip or label for the 'Dislike' action." + }, + "commentActionLabel": "Comment", + "@commentActionLabel": { + "description": "Tooltip or label for the 'Comment' action." + }, + "moreActionLabel": "More", + "@moreActionLabel": { + "description": "Tooltip or label for the 'More' actions icon." + }, + "rateAppPromptTitle": "Enjoying the app?", + "@rateAppPromptTitle": { + "description": "Title for the in-app bottom sheet that prompts the user to rate the app." + }, + "rateAppPromptBody": "Your feedback helps us improve. Would you mind rating us?", + "@rateAppPromptBody": { + "description": "Body text for the in-app rating prompt bottom sheet." + }, + "rateAppPromptYesButton": "It's Great!", + "@rateAppPromptYesButton": { + "description": "Button text for a positive response to the rating prompt." + }, + "rateAppPromptNoButton": "Needs Work", + "@rateAppPromptNoButton": { + "description": "Button text for a negative response to the rating prompt." + }, + "feedbackPromptTitle": "How can we improve?", + "@feedbackPromptTitle": { + "description": "Title for the bottom sheet that asks for detailed feedback after a negative rating prompt response." + }, + "feedbackPromptReasonUI": "UI / Design", + "@feedbackPromptReasonUI": { + "description": "Feedback reason related to UI and design." + }, + "feedbackPromptReasonPerformance": "Performance / Speed", + "@feedbackPromptReasonPerformance": { + "description": "Feedback reason related to app performance." + }, + "feedbackPromptReasonContent": "Content Quality", + "@feedbackPromptReasonContent": { + "description": "Feedback reason related to the quality of news content." + }, + "feedbackPromptReasonOther": "Other", + "@feedbackPromptReasonOther": { + "description": "Feedback reason for issues not listed." + }, + "feedbackPromptSubmitButton": "Submit Feedback", + "@feedbackPromptSubmitButton": { + "description": "Button text to submit detailed feedback." + }, + "rateAppNegativeFollowUpTitle_1": "How are we doing now?", + "@rateAppNegativeFollowUpTitle_1": { + "description": "First variation of the follow-up title for the 'Rate App' prompt after a user has given negative feedback." + }, + "rateAppNegativeFollowUpTitle_2": "Have we improved?", + "@rateAppNegativeFollowUpTitle_2": { + "description": "Second variation of the follow-up title for the 'Rate App' prompt after a user has given negative feedback." + }, + "rateAppNegativeFollowUpBody_1": "We've been working on your feedback. Would you reconsider your rating?", + "@rateAppNegativeFollowUpBody_1": { + "description": "First variation of the follow-up body text for the 'Rate App' prompt after a user has given negative feedback." + }, + "rateAppNegativeFollowUpBody_2": "We value your opinion. Let us know if things are better.", + "@rateAppNegativeFollowUpBody_2": { + "description": "Second variation of the follow-up body text for the 'Rate App' prompt after a user has given negative feedback." + }, + "noCommentsYet": "No comments yet.", + "@noCommentsYet": { + "description": "Message displayed when there are no comments on a headline." + }, + "commentInputNoReactionHint": "React to add a comment", + "@commentInputNoReactionHint": { + "description": "Hint text for the comment input field when the user has not yet reacted." + }, + "headlineReportReasonMisinformation": "Misinformation or Fake News", + "@headlineReportReasonMisinformation": { + "description": "Report reason for factually incorrect content." + }, + "headlineReportReasonClickbait": "Clickbait or Misleading Title", + "@headlineReportReasonClickbait": { + "description": "Report reason for a clickbait headline." + }, + "headlineReportReasonOffensive": "Offensive or Hate Speech", + "@headlineReportReasonOffensive": { + "description": "Report reason for offensive content." + }, + "headlineReportReasonSpam": "Spam or Scam", + "@headlineReportReasonSpam": { + "description": "Report reason for spam or fraudulent content." + }, + "headlineReportReasonBrokenLink": "Broken Link", + "@headlineReportReasonBrokenLink": { + "description": "Report reason for a non-working article URL." + }, + "headlineReportReasonPaywalled": "Paywalled Content", + "@headlineReportReasonPaywalled": { + "description": "Report reason for content that requires a subscription." + }, + "sourceReportReasonLowQuality": "Low-Quality Journalism", + "@sourceReportReasonLowQuality": { + "description": "Report reason for a source with poor content." + }, + "sourceReportReasonHighAdDensity": "Excessive Ads or Popups", + "@sourceReportReasonHighAdDensity": { + "description": "Report reason for a source with a bad user experience due to ads." + }, + "sourceReportReasonFrequentPaywalls": "Frequent Paywalls", + "@sourceReportReasonFrequentPaywalls": { + "description": "Report reason for a source that often requires a subscription." + }, + "sourceReportReasonImpersonation": "Impersonation", + "@sourceReportReasonImpersonation": { + "description": "Report reason for a source pretending to be another entity." + }, + "sourceReportReasonMisinformation": "Misinformation", + "@sourceReportReasonMisinformation": { + "description": "Report reason for a source pretending to be another entity." + }, + "commentReportReasonSpam": "Spam or Advertising", + "@commentReportReasonSpam": { + "description": "Report reason for a comment that is spam." + }, + "commentReportReasonHarassment": "Harassment or Bullying", + "@commentReportReasonHarassment": { + "description": "Report reason for a comment that is abusive." + }, + "commentReportReasonHateSpeech": "Hate Speech", + "@commentReportReasonHateSpeech": { + "description": "Report reason for a comment containing hate speech." + }, + "limitReachedTitle": "Limit Reached", + "@limitReachedTitle": { + "description": "Generic title for when a user hits a content limit." + }, + "manageMyContentButton": "Manage My Content", + "@manageMyContentButton": { + "description": "Button text that navigates the user to a page where they can manage their content (e.g., unfollow items)." + }, + "upgradeButton": "Upgrade", + "@upgradeButton": { + "description": "Button text prompting the user to upgrade their subscription plan." + }, + "createAccountButton": "Create Account", + "@createAccountButton": { + "description": "Button text prompting an anonymous user to create an account." + }, + "gotItButton": "Got It", + "@gotItButton": { + "description": "A simple acknowledgement button." + }, + "commentsPageTitle": "Comments", + "@commentsPageTitle": { + "description": "Title for the page or bottom sheet that displays comments for a headline." + }, + "commentsCount": "{count,plural, =1{1 Comment}other{{count} Comments}}", + "@commentsCount": { + "description": "Label for a button that shows the number of comments and opens the comments view.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "limitReachedGuestUserTitle": "Account Required", + "@limitReachedGuestUserTitle": { + "description": "Title for the bottom sheet when a guest user tries to perform an action reserved for authenticated users (e.g., comment, react)." + }, + "limitReachedGuestUserBody": "Please create a free account or sign in to engage with the community and use this feature.", + "@limitReachedGuestUserBody": { + "description": "Body text for the bottom sheet when a guest user tries to perform an action reserved for authenticated users." + }, + "limitReachedBodyFollow": "You have reached your limit for followed items. To follow more, please review your existing followed content.", + "@limitReachedBodyFollow": { + "description": "Body text when a user tries to follow more items (topics, sources, countries) than their plan allows." + }, + "limitReachedBodySave": "You have reached your limit for saved articles. To save more, please review your existing saved articles.", + "@limitReachedBodySave": { + "description": "Body text when a user tries to save more articles than their plan allows." + }, + "limitReachedBodySaveFilters": "You have reached your limit for saved filters. To create new ones, please review your existing filters.", + "@limitReachedBodySaveFilters": { + "description": "Body text when a user tries to save more filters than their plan allows." + }, + "limitReachedBodyPinFilters": "You have reached your limit for pinned filters. To pin a new one, please un-pin an existing filter.", + "@limitReachedBodyPinFilters": { + "description": "Body text when a user tries to pin more filters than their plan allows." + }, + "limitReachedBodySubscribeToNotifications": "You have reached your limit for notification subscriptions. To subscribe to new alerts, please review your existing subscriptions.", + "@limitReachedBodySubscribeToNotifications": { + "description": "Body text when a user tries to subscribe to more notifications than their plan allows." + }, + "limitReachedBodyComments": "You have reached your daily limit for posting comments. Please try again tomorrow.", + "@limitReachedBodyComments": { + "description": "Body text when a user tries to post more comments than their daily limit allows." + }, + "limitReachedBodyReactions": "You have reached your daily limit for reactions. Please try again tomorrow.", + "@limitReachedBodyReactions": { + "description": "Body text when a user tries to react more than their daily limit allows." + }, + "limitReachedBodyReports": "You have reached your daily limit for submitting reports. Please try again tomorrow.", + "@limitReachedBodyReports": { + "description": "Body text when a user tries to submit more reports than their daily limit allows." + }, + "commentEditButtonLabel": "Update", + "@commentEditButtonLabel": { + "description": "Label for the button to update an existing comment." + }, + "commentPostFailureSnackbar": "Failed to post comment. Please try again.", + "@commentPostFailureSnackbar": { + "description": "Snackbar message shown when posting a comment fails." + }, + "commentUpdateFailureSnackbar": "Failed to update comment. Please try again.", + "@commentUpdateFailureSnackbar": { + "description": "Snackbar message shown when updating a comment fails." + }, + "commentInputDisabledHint": "Sign in to join the conversation", + "@commentInputDisabledHint": { + "description": "Hint text for the comment input field when the user is a guest." + }, + "commentInputExistingHint": "You have already commented on this headline.", + "@commentInputExistingHint": { + "description": "Hint text for the comment input field when the user has already posted a comment." + }, + "commenterName": "User {id}", + "@commenterName": { + "description": "Display name for a commenter when their full user object is not available, showing a partial ID.", + "placeholders": { + "id": { + "type": "String", + "example": "1a2b" + } + } } } \ No newline at end of file diff --git a/lib/router/router.dart b/lib/router/router.dart index 74d85f49..f55d9ea8 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -55,6 +55,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/settings/view/la import 'package:flutter_news_app_mobile_client_full_source_code/settings/view/notification_settings_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/settings/view/settings_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/settings/view/theme_settings_page.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/multi_select_search_page.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; @@ -363,7 +364,27 @@ GoRouter createRouter({ GoRoute( path: Routes.accountSavedHeadlines, name: Routes.accountSavedHeadlinesName, - builder: (context, state) => const SavedHeadlinesPage(), + builder: (context, state) => BlocProvider( + create: (context) { + final appBloc = context.read(); + final initialUserContentPreferences = + appBloc.state.userContentPreferences; + return HeadlinesFeedBloc( + headlinesRepository: context.read>(), + feedDecoratorService: FeedDecoratorService(), + adService: context.read(), + appBloc: appBloc, + inlineAdCacheService: context.read(), + feedCacheService: context.read(), + initialUserContentPreferences: initialUserContentPreferences, + engagementRepository: context + .read>(), + contentLimitationService: context + .read(), + ); + }, + child: const SavedHeadlinesPage(), + ), ), ], ), @@ -476,6 +497,10 @@ GoRouter createRouter({ feedCacheService: context.read(), initialUserContentPreferences: initialUserContentPreferences, + engagementRepository: context + .read>(), + contentLimitationService: context + .read(), ); }, child: child, diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 4f97a3d6..cc02a5e3 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -55,6 +55,8 @@ abstract final class Routes { static const feedFilterEventCountriesName = 'feedFilterEventCountries'; static const savedHeadlineFilters = 'saved-headline-filters'; static const savedHeadlineFiltersName = 'savedHeadlineFilters'; + static const engagement = 'engagement/:headlineId'; + static const engagementName = 'engagement'; // Discover static const sourceList = 'source-list/:sourceType'; diff --git a/lib/shared/services/content_limitation_service.dart b/lib/shared/services/content_limitation_service.dart index 9782de73..a7383ac9 100644 --- a/lib/shared/services/content_limitation_service.dart +++ b/lib/shared/services/content_limitation_service.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:logging/logging.dart'; /// Defines the specific type of content-related action a user is trying to /// perform, which may be subject to limitations. @@ -16,14 +20,23 @@ enum ContentAction { /// The action of following a country. followCountry, - /// The action of saving a headline filter. - saveHeadlineFilter, + /// The action of saving a filter. + saveFilter, + + /// The action of pinning a filter. + pinFilter, + + /// The action of subscribing to notifications for a saved filter. + subscribeToSavedFilterNotifications, + + /// The action of posting a comment. + postComment, - /// The action of pinning a headline filter. - pinHeadlineFilter, + /// The action of reacting to a piece of content. + reactToContent, - /// The action of subscribing to notifications for a headline filter. - subscribeToHeadlineFilterNotifications, + /// The action of submitting a report. + submitReport, } /// Defines the outcome of a content limitation check. @@ -46,31 +59,153 @@ enum LimitationStatus { /// a content-related action based on their role and remote configuration limits. /// /// This service acts as the single source of truth for content limitations, -/// ensuring that rules for actions like bookmarking or following are applied -/// consistently throughout the application. +/// ensuring that rules are applied consistently throughout the application. +/// +/// It is a stateful, caching service that proactively fetches daily action +/// counts for the current user to provide fast, client-side limit checks. /// {@endtemplate} class ContentLimitationService { /// {@macro content_limitation_service} - const ContentLimitationService({required AppBloc appBloc}) - : _appBloc = appBloc; + ContentLimitationService({ + required DataRepository engagementRepository, + required DataRepository reportRepository, + required Duration cacheDuration, + required Logger logger, + }) : _engagementRepository = engagementRepository, + _reportRepository = reportRepository, + _cacheDuration = cacheDuration, + _logger = logger; + + final DataRepository _engagementRepository; + final DataRepository _reportRepository; + final Duration _cacheDuration; + final Logger _logger; + + late final AppBloc _appBloc; + StreamSubscription? _appBlocSubscription; + + // Internal cache for daily action counts. + int? _commentCount; + int? _reactionCount; + int? _reportCount; + DateTime? _countsLastFetchedAt; + String? _cachedForUserId; + + /// Initializes the service by subscribing to AppBloc state changes. + /// + /// This triggers the proactive fetching of daily action counts whenever the + /// user's authentication state changes. + void init({required AppBloc appBloc}) { + _logger.info('ContentLimitationService initializing...'); + _appBloc = appBloc; + _appBlocSubscription = appBloc.stream.listen(_onAppStateChanged); + // Trigger initial fetch if a user is already present. + if (appBloc.state.user != null) { + _fetchDailyCounts(_appBloc.state.user!.id); + } + } + + /// Disposes of the service, cancelling any active subscriptions. + void dispose() { + _logger.info('ContentLimitationService disposing...'); + _appBlocSubscription?.cancel(); + } - final AppBloc _appBloc; + void _onAppStateChanged(AppState appState) { + final newUserId = appState.user?.id; + + // If the user ID has changed (login/logout/transition), clear the cache + // and potentially trigger a new fetch. + if (newUserId != _cachedForUserId) { + _logger.info( + 'User changed from $_cachedForUserId to $newUserId. ' + 'Clearing daily action count cache.', + ); + _clearCache(); + if (newUserId != null) { + _fetchDailyCounts(newUserId); + } + } + } + + void _clearCache() { + _commentCount = null; + _reactionCount = null; + _reportCount = null; + _countsLastFetchedAt = null; + _cachedForUserId = null; + } + + Future _fetchDailyCounts(String userId) async { + _logger.info('Fetching daily action counts for user $userId...'); + _cachedForUserId = userId; + + try { + final twentyFourHoursAgo = DateTime.now().subtract( + const Duration(hours: 24), + ); + + // Fetch all counts concurrently for performance. + final [commentCount, reactionCount, reportCount] = await Future.wait( + [ + _engagementRepository.count( + userId: userId, + filter: { + 'comment': {r'$exists': true, r'$ne': null}, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ), + _engagementRepository.count( + userId: userId, + filter: { + 'reaction': {r'$exists': true, r'$ne': null}, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ), + _reportRepository.count( + userId: userId, + filter: { + 'reporterUserId': userId, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ), + ], + ); + + _commentCount = commentCount; + _reactionCount = reactionCount; + _reportCount = reportCount; + _countsLastFetchedAt = DateTime.now(); + + _logger.info( + 'Successfully fetched daily counts for user $userId: ' + 'Comments: $_commentCount, Reactions: $_reactionCount, Reports: $_reportCount', + ); + } catch (e, s) { + _logger.severe( + 'Failed to fetch daily action counts for user $userId.', + e, + s, + ); + // Clear cache on failure to ensure a retry on the next check. + _clearCache(); + } + } /// Checks if the current user is allowed to perform a given [action]. /// /// Returns a [LimitationStatus] indicating whether the action is allowed or /// if a specific limit has been reached. - LimitationStatus checkAction( + Future checkAction( ContentAction action, { PushNotificationSubscriptionDeliveryType? deliveryType, - }) { + }) async { final state = _appBloc.state; final user = state.user; final preferences = state.userContentPreferences; final remoteConfig = state.remoteConfig; - // Fail open: If essential data is missing, allow the action to prevent - // blocking users due to an incomplete app state. + // Fail open: If essential data is missing, allow the action. if (user == null || preferences == null || remoteConfig == null) { return LimitationStatus.allowed; } @@ -78,121 +213,111 @@ class ContentLimitationService { final limits = remoteConfig.user.limits; final role = user.appRole; + // Business Rule: Guest users are not allowed to engage or report. + if (role == AppUserRole.guestUser) { + switch (action) { + case ContentAction.postComment: + case ContentAction.reactToContent: + case ContentAction.submitReport: + return LimitationStatus.anonymousLimitReached; + case ContentAction.bookmarkHeadline: + case ContentAction.followTopic: + case ContentAction.followSource: + case ContentAction.followCountry: + case ContentAction.saveFilter: + case ContentAction.pinFilter: + case ContentAction.subscribeToSavedFilterNotifications: + break; // Continue to normal check for guest. + } + } + + // Check daily limits, refreshing cache if necessary. + final isCacheStale = + _countsLastFetchedAt == null || + DateTime.now().difference(_countsLastFetchedAt!) > _cacheDuration; + + if (isCacheStale && _cachedForUserId == user.id) { + await _fetchDailyCounts(user.id); + } + switch (action) { + // Persisted preference checks (synchronous) case ContentAction.bookmarkHeadline: - final count = preferences.savedHeadlines.length; final limit = limits.savedHeadlines[role]; - - // If no limit is defined for the role, allow the action. - if (limit == null) return LimitationStatus.allowed; - - if (count >= limit) { + if (limit != null && preferences.savedHeadlines.length >= limit) { return _getLimitationStatusForRole(role); } - // Check if the user has reached the limit for saving filters. - case ContentAction.saveHeadlineFilter: - final count = preferences.savedHeadlineFilters.length; - final limitConfig = limits.savedHeadlineFilters[role]; - - // If no limit config is defined for the role, allow the action. - if (limitConfig == null) return LimitationStatus.allowed; - - if (count >= limitConfig.total) { + case ContentAction.followTopic: + case ContentAction.followSource: + case ContentAction.followCountry: + final limit = limits.followedItems[role]; + if (limit == null) return LimitationStatus.allowed; + final count = switch (action) { + ContentAction.followTopic => preferences.followedTopics.length, + ContentAction.followSource => preferences.followedSources.length, + ContentAction.followCountry => preferences.followedCountries.length, + _ => 0, + }; + if (count >= limit) return _getLimitationStatusForRole(role); + + case ContentAction.saveFilter: + final limit = limits.savedHeadlineFilters[role]?.total; + if (limit != null && preferences.savedHeadlineFilters.length >= limit) { return _getLimitationStatusForRole(role); } - case ContentAction.pinHeadlineFilter: - final count = preferences.savedHeadlineFilters - .where((filter) => filter.isPinned) - .length; + case ContentAction.pinFilter: final limit = limits.savedHeadlineFilters[role]?.pinned; - - if (limit == null) return LimitationStatus.allowed; - - if (count >= limit) { + if (limit != null && + preferences.savedHeadlineFilters.where((f) => f.isPinned).length >= + limit) { return _getLimitationStatusForRole(role); } - case ContentAction.subscribeToHeadlineFilterNotifications: + case ContentAction.subscribeToSavedFilterNotifications: final subscriptionLimits = limits.savedHeadlineFilters[role]?.notificationSubscriptions; - - // If no subscription limits are defined for the role, allow the action. if (subscriptionLimits == null) return LimitationStatus.allowed; final currentCounts = {}; for (final filter in preferences.savedHeadlineFilters) { for (final type in filter.deliveryTypes) { - currentCounts.update(type, (value) => value + 1, ifAbsent: () => 1); + currentCounts.update(type, (v) => v + 1, ifAbsent: () => 1); } } - // If a specific delivery type is provided, check the limit for that - // type only. This is used by the SaveFilterDialog UI. if (deliveryType != null) { final limitForType = subscriptionLimits[deliveryType] ?? 0; final currentCountForType = currentCounts[deliveryType] ?? 0; - if (currentCountForType >= limitForType) { return _getLimitationStatusForRole(role); } - } else { - // If no specific type is provided, perform a general check to see - // if the user can subscribe to *any* notification type. This maintains - // backward compatibility for broader checks. - final canSubscribeToAny = subscriptionLimits.entries.any((entry) { - final limit = entry.value; - final currentCount = currentCounts[entry.key] ?? 0; - return currentCount < limit; - }); - - if (!canSubscribeToAny) { - return _getLimitationStatusForRole(role); - } } - case ContentAction.followTopic: - case ContentAction.followSource: - case ContentAction.followCountry: - final limit = limits.followedItems[role]; - - // Determine the count for the specific item type being followed. - final int count; - switch (action) { - case ContentAction.followTopic: - count = preferences.followedTopics.length; - case ContentAction.followSource: - count = preferences.followedSources.length; - case ContentAction.followCountry: - count = preferences.followedCountries.length; - case ContentAction.bookmarkHeadline: - // This case is handled above and will not be reached here. - count = 0; - case ContentAction.saveHeadlineFilter: - // This case is handled above and will not be reached here. - count = 0; - case ContentAction.pinHeadlineFilter: - case ContentAction.subscribeToHeadlineFilterNotifications: - // These cases are handled above and will not be reached here. - count = 0; + // Daily action checks (asynchronous, cached) + case ContentAction.postComment: + final limit = limits.commentsPerDay[role]; + if (limit != null && (_commentCount ?? 0) >= limit) { + return _getLimitationStatusForRole(role); } - // If no limit is defined for the role, allow the action. - if (limit == null) return LimitationStatus.allowed; + case ContentAction.reactToContent: + final limit = limits.reactionsPerDay[role]; + if (limit != null && (_reactionCount ?? 0) >= limit) { + return _getLimitationStatusForRole(role); + } - if (count >= limit) { + case ContentAction.submitReport: + final limit = limits.reportsPerDay[role]; + if (limit != null && (_reportCount ?? 0) >= limit) { return _getLimitationStatusForRole(role); } } - // If no limit was hit, the action is allowed. return LimitationStatus.allowed; } - /// Maps an [AppUserRole] to the corresponding [LimitationStatus]. - /// - /// This helper function ensures a consistent mapping when a limit is reached. LimitationStatus _getLimitationStatusForRole(AppUserRole role) { switch (role) { case AppUserRole.guestUser: diff --git a/lib/shared/widgets/content_limitation_bottom_sheet.dart b/lib/shared/widgets/content_limitation_bottom_sheet.dart index 32c51276..f1298ec8 100644 --- a/lib/shared/widgets/content_limitation_bottom_sheet.dart +++ b/lib/shared/widgets/content_limitation_bottom_sheet.dart @@ -1,5 +1,8 @@ +import 'package:core/core.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/services/content_limitation_service.dart'; import 'package:go_router/go_router.dart'; @@ -9,126 +12,35 @@ import 'package:ui_kit/ui_kit.dart' hide UiKitLocalizations; /// A bottom sheet that informs the user about content limitations and provides /// relevant actions based on their status. /// {@endtemplate} -class ContentLimitationBottomSheet extends StatelessWidget { +class ContentLimitationBottomSheet extends StatefulWidget { /// {@macro content_limitation_bottom_sheet} - const ContentLimitationBottomSheet({required this.status, super.key}); - - /// The limitation status that determines the content of the bottom sheet. - final LimitationStatus status; - - @override - Widget build(BuildContext context) { - // Use a switch to build the appropriate view based on the status. - // Each case returns a dedicated private widget for clarity. - switch (status) { - case LimitationStatus.anonymousLimitReached: - return const _AnonymousLimitView(); - case LimitationStatus.standardUserLimitReached: - return const _StandardUserLimitView(); - case LimitationStatus.premiumUserLimitReached: - return const _PremiumUserLimitView(); - case LimitationStatus.allowed: - // If the action is allowed, no UI is needed. - return const SizedBox.shrink(); - } - } -} + const ContentLimitationBottomSheet({ + required this.title, + required this.body, + required this.buttonText, + this.onButtonPressed, + super.key, + }); -/// A private widget to show when an anonymous user hits a limit. -class _AnonymousLimitView extends StatelessWidget { - const _AnonymousLimitView(); + /// The title of the bottom sheet. + final String title; - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; + /// The body text of the bottom sheet. + final String body; - return _BaseLimitView( - icon: Icons.person_add_alt_1_outlined, - title: l10n.anonymousLimitTitle, - body: l10n.anonymousLimitBody, - child: ElevatedButton( - onPressed: () { - // Pop the bottom sheet first. - Navigator.of(context).pop(); - // Then navigate to the account linking page. - context.pushNamed(Routes.accountLinkingName); - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), - ), - child: Text(l10n.anonymousLimitButton), - ), - ); - } -} + /// The text for the action button. + final String buttonText; -/// A private widget to show when a standard (free) user hits a limit. -class _StandardUserLimitView extends StatelessWidget { - const _StandardUserLimitView(); + /// The callback executed when the action button is pressed. + final VoidCallback? onButtonPressed; @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - - return _BaseLimitView( - icon: Icons.workspace_premium_outlined, - title: l10n.standardLimitTitle, - body: l10n.standardLimitBody, - child: ElevatedButton( - // TODO(fulleni): Implement account upgrade flow. - // The upgrade flow is not yet implemented, so the button is disabled. - onPressed: null, - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), - ), - child: Text(l10n.standardLimitButton), - ), - ); - } + State createState() => + _ContentLimitationBottomSheetState(); } -/// A private widget to show when a premium user hits a limit. -class _PremiumUserLimitView extends StatelessWidget { - const _PremiumUserLimitView(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - - return _BaseLimitView( - icon: Icons.inventory_2_outlined, - title: l10n.premiumLimitTitle, - body: l10n.premiumLimitBody, - child: ElevatedButton( - onPressed: () { - // Pop the bottom sheet first. - Navigator.of(context).pop(); - // Then navigate to the page for managing followed items. - context.goNamed(Routes.manageFollowedItemsName); - }, - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(AppSpacing.xxl + AppSpacing.sm), - ), - child: Text(l10n.premiumLimitButton), - ), - ); - } -} - -/// A base layout for the content limitation views to reduce duplication. -class _BaseLimitView extends StatelessWidget { - const _BaseLimitView({ - required this.icon, - required this.title, - required this.body, - required this.child, - }); - - final IconData icon; - final String title; - final String body; - final Widget child; - +class _ContentLimitationBottomSheetState + extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -141,20 +53,112 @@ class _BaseLimitView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: AppSpacing.xxl * 1.5, color: colorScheme.primary), + Icon( + Icons.block, + size: AppSpacing.xxl * 1.5, + color: colorScheme.primary, + ), const SizedBox(height: AppSpacing.lg), Text( - title, + widget.title, style: textTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: AppSpacing.md), - Text(body, style: textTheme.bodyLarge, textAlign: TextAlign.center), + Text( + widget.body, + style: textTheme.bodyLarge, + textAlign: TextAlign.center, + ), const SizedBox(height: AppSpacing.lg), - child, + ElevatedButton( + onPressed: widget.onButtonPressed, + child: Text(widget.buttonText), + ), ], ), ), ); } } + +/// A shared helper function to show the content limitation bottom sheet. +/// +/// This function centralizes the logic for determining the sheet's content +/// based on the user's role and the specific limitation they have encountered. +void showContentLimitationBottomSheet({ + required BuildContext context, + required LimitationStatus status, + required ContentAction action, +}) { + final l10n = AppLocalizations.of(context); + final userRole = context.read().state.user?.appRole; + + final content = _getBottomSheetContent( + context: context, + l10n: l10n, + status: status, + userRole: userRole, + action: action, + ); + + showModalBottomSheet( + context: context, + builder: (_) => ContentLimitationBottomSheet( + title: content.title, + body: content.body, + buttonText: content.buttonText, + onButtonPressed: content.onPressed, + ), + ); +} + +/// Determines the content for the [ContentLimitationBottomSheet] based on +/// the user's role and the limitation status. +({String title, String body, String buttonText, VoidCallback? onPressed}) +_getBottomSheetContent({ + required BuildContext context, + required AppLocalizations l10n, + required LimitationStatus status, + required AppUserRole? userRole, + required ContentAction action, +}) { + switch (status) { + case LimitationStatus.anonymousLimitReached: + return ( + title: l10n.limitReachedGuestUserTitle, + body: l10n.limitReachedGuestUserBody, + buttonText: l10n.createAccountButton, + onPressed: () { + Navigator.of(context).pop(); + context.goNamed(Routes.accountLinkingName); + }, + ); + case LimitationStatus.standardUserLimitReached: + case LimitationStatus.premiumUserLimitReached: + final body = switch (action) { + ContentAction.bookmarkHeadline => l10n.limitReachedBodySave, + ContentAction.followTopic || + ContentAction.followSource || + ContentAction.followCountry => l10n.limitReachedBodyFollow, + ContentAction.postComment => l10n.limitReachedBodyComments, + ContentAction.reactToContent => l10n.limitReachedBodyReactions, + ContentAction.submitReport => l10n.limitReachedBodyReports, + ContentAction.saveFilter => l10n.limitReachedBodySaveFilters, + ContentAction.pinFilter => l10n.limitReachedBodyPinFilters, + ContentAction.subscribeToSavedFilterNotifications => + l10n.limitReachedBodySubscribeToNotifications, + }; + return ( + title: l10n.limitReachedTitle, + body: body, + buttonText: l10n.gotItButton, + onPressed: () { + Navigator.of(context).pop(); + // TODO(fulleni): Navigate to content management or upgrade page. + }, + ); + case LimitationStatus.allowed: + return (title: '', body: '', buttonText: '', onPressed: null); + } +} diff --git a/lib/shared/widgets/feed_core/headline_source_row.dart b/lib/shared/widgets/feed_core/headline_source_row.dart index 0ff18294..607e0741 100644 --- a/lib/shared/widgets/feed_core/headline_source_row.dart +++ b/lib/shared/widgets/feed_core/headline_source_row.dart @@ -49,12 +49,12 @@ class _HeadlineSourceRowState extends State { ); final sourceTextStyle = textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant.withOpacity(0.8), fontWeight: FontWeight.w500, ); final dateTextStyle = textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant.withOpacity(0.7), + color: colorScheme.onSurfaceVariant.withOpacity(0.6), ); return Row( @@ -99,20 +99,26 @@ class _HeadlineSourceRowState extends State { children: [ if (formattedDate.isNotEmpty) Padding( - padding: const EdgeInsets.only(right: AppSpacing.xs), + padding: const EdgeInsets.only(right: AppSpacing.md), child: Text(formattedDate, style: dateTextStyle), ), - // Use InkWell + Icon instead of IconButton to have precise control - // over padding and constraints, avoiding the default minimum - // touch target size that misaligns the row height on native. - InkWell( - customBorder: const CircleBorder(), - onTap: () => showModalBottomSheet( + IconButton( + icon: Icon( + Icons.more_vert, + // Use the same color as the date text for visual consistency. + color: dateTextStyle?.color, + ), + // Adjust icon size to be more harmonious with the text. + iconSize: AppSpacing.lg, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => showModalBottomSheet( context: context, - builder: (_) => - HeadlineActionsBottomSheet(headline: widget.headline), + builder: (_) => BlocProvider.value( + value: context.read(), + child: HeadlineActionsBottomSheet(headline: widget.headline), + ), ), - child: const Icon(Icons.more_horiz, size: 20), ), ], ), diff --git a/lib/shared/widgets/feed_core/headline_tile_image_start.dart b/lib/shared/widgets/feed_core/headline_tile_image_start.dart index f65f70f1..94452554 100644 --- a/lib/shared/widgets/feed_core/headline_tile_image_start.dart +++ b/lib/shared/widgets/feed_core/headline_tile_image_start.dart @@ -1,8 +1,11 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_source_row.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_tap_handler.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/engagement/widgets/headline_actions_row.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template headline_tile_image_start} @@ -42,76 +45,98 @@ class HeadlineTileImageStart extends StatelessWidget { horizontal: AppSpacing.paddingMedium, vertical: AppSpacing.xs, ), - child: InkWell( - onTap: - onHeadlineTap ?? - () => HeadlineTapHandler.handleHeadlineTap(context, headline), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 72, - height: 72, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppSpacing.xs), - child: Image.network( - headline.imageUrl, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - errorBuilder: (context, error, stackTrace) => ColoredBox( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: AppSpacing.xl, - ), - ), - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - HeadlineSourceRow(headline: headline), - const SizedBox(height: AppSpacing.sm), - Text.rich( - TextSpan( - children: [ - if (headline.isBreaking) - TextSpan( - text: '${l10n.breakingNewsPrefix} - ', - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - color: colorScheme.primary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: + onHeadlineTap ?? + () => HeadlineTapHandler.handleHeadlineTap(context, headline), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + height: 72, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppSpacing.xs), + child: Image.network( + headline.imageUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: const Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + errorBuilder: (context, error, stackTrace) => + ColoredBox( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: AppSpacing.xl, ), ), - TextSpan(text: headline.title), - ], ), - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - ], - ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HeadlineSourceRow(headline: headline), + const SizedBox(height: AppSpacing.sm), + Text.rich( + TextSpan( + children: [ + if (headline.isBreaking) + TextSpan( + text: '${l10n.breakingNewsPrefix} - ', + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.primary, + ), + ), + TextSpan(text: headline.title), + ], + ), + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], ), - ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 0, + AppSpacing.md, + AppSpacing.xs, + ), + child: BlocBuilder( + builder: (context, state) { + return HeadlineActionsRow( + headline: headline, + engagements: state.engagementsMap[headline.id] ?? [], + ); + }, + ), ), - ), + ], ), ); } diff --git a/lib/shared/widgets/feed_core/headline_tile_image_top.dart b/lib/shared/widgets/feed_core/headline_tile_image_top.dart index 45a47b15..b3930c5a 100644 --- a/lib/shared/widgets/feed_core/headline_tile_image_top.dart +++ b/lib/shared/widgets/feed_core/headline_tile_image_top.dart @@ -1,8 +1,11 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_source_row.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_tap_handler.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/engagement/widgets/headline_actions_row.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template headline_tile_image_top} @@ -91,42 +94,48 @@ class HeadlineTileImageTop extends StatelessWidget { AppSpacing.md, AppSpacing.md, AppSpacing.md, - AppSpacing.md, + 0, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: InkWell( - onTap: - onHeadlineTap ?? - () => HeadlineTapHandler.handleHeadlineTap( - context, - headline, - ), - child: Text.rich( + child: InkWell( + onTap: + onHeadlineTap ?? + () => HeadlineTapHandler.handleHeadlineTap(context, headline), + child: Text.rich( + TextSpan( + children: [ + if (headline.isBreaking) TextSpan( - children: [ - if (headline.isBreaking) - TextSpan( - text: '${l10n.breakingNewsPrefix} - ', - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, - color: colorScheme.primary, - ), - ), - TextSpan(text: headline.title), - ], - ), - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w500, + text: '${l10n.breakingNewsPrefix} - ', + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.primary, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), + TextSpan(text: headline.title), + ], ), - ], + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + ), + child: BlocBuilder( + builder: (context, state) { + return HeadlineActionsRow( + headline: headline, + engagements: state.engagementsMap[headline.id] ?? [], + ); + }, ), ), ], diff --git a/lib/shared/widgets/feed_core/headline_tile_text_only.dart b/lib/shared/widgets/feed_core/headline_tile_text_only.dart index 38c033a3..0c3460bb 100644 --- a/lib/shared/widgets/feed_core/headline_tile_text_only.dart +++ b/lib/shared/widgets/feed_core/headline_tile_text_only.dart @@ -1,8 +1,11 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_source_row.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/feed_core/headline_tap_handler.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/engagement/widgets/headline_actions_row.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template headline_tile_text_only} @@ -79,6 +82,18 @@ class HeadlineTileTextOnly extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), + Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: BlocBuilder( + builder: (context, state) { + return HeadlineActionsRow( + headline: headline, + engagements: + state.engagementsMap[headline.id] ?? [], + ); + }, + ), + ), ], ), ), diff --git a/lib/shared/widgets/headline_actions_bottom_sheet.dart b/lib/shared/widgets/headline_actions_bottom_sheet.dart index 29021516..730db8e3 100644 --- a/lib/shared/widgets/headline_actions_bottom_sheet.dart +++ b/lib/shared/widgets/headline_actions_bottom_sheet.dart @@ -3,14 +3,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/reporting/view/report_content_bottom_sheet.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:ui_kit/ui_kit.dart'; /// {@template headline_actions_bottom_sheet} -/// A modal bottom sheet that displays actions for a given headline, such as -/// sharing and bookmarking. +/// A modal bottom sheet that displays secondary actions for a given headline, +/// such as saving, sharing, and reporting. /// {@endtemplate} -class HeadlineActionsBottomSheet extends StatelessWidget { +class HeadlineActionsBottomSheet extends StatefulWidget { /// {@macro headline_actions_bottom_sheet} const HeadlineActionsBottomSheet({required this.headline, super.key}); @@ -18,71 +18,84 @@ class HeadlineActionsBottomSheet extends StatelessWidget { final Headline headline; @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - return BlocBuilder( - builder: (context, state) { - final isBookmarked = - state.userContentPreferences?.savedHeadlines.any( - (saved) => saved.id == headline.id, - ) ?? - false; + State createState() => + _HeadlineActionsBottomSheetState(); +} - return Wrap( - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Text( - l10n.headlineActionsModalTitle, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - ListTile( - leading: const Icon(Icons.share_outlined), - title: Text(l10n.shareActionLabel), - onTap: () { - Navigator.of(context).pop(); - Share.share(headline.url); - }, - ), - ListTile( - leading: Icon( - isBookmarked - ? Icons.bookmark_added - : Icons.bookmark_add_outlined, - ), - title: Text( - isBookmarked - ? l10n.removeBookmarkActionLabel - : l10n.bookmarkActionLabel, - ), - onTap: () { - final userContentPreferences = state.userContentPreferences; - if (userContentPreferences == null) return; +class _HeadlineActionsBottomSheetState + extends State { + final bool _isBookmarking = false; - final currentSaved = List.from( - userContentPreferences.savedHeadlines, - ); + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final isBookmarked = context.select( + (bloc) => + bloc.state.userContentPreferences?.savedHeadlines.any( + (h) => h.id == widget.headline.id, + ) ?? + false, + ); - if (isBookmarked) { - currentSaved.removeWhere((h) => h.id == headline.id); - } else { - currentSaved.insert(0, headline); - } + final remoteConfig = context.watch().state.remoteConfig; + final communityConfig = remoteConfig?.features.community; + final isHeadlineReportingEnabled = + (communityConfig?.enabled ?? false) && + (communityConfig?.reporting.headlineReportingEnabled ?? false); - context.read().add( - AppUserContentPreferencesChanged( - preferences: userContentPreferences.copyWith( - savedHeadlines: currentSaved, - ), - ), - ); - Navigator.of(context).pop(); - }, - ), - ], - ); - }, + return Wrap( + children: [ + ListTile( + leading: _isBookmarking + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(isBookmarked ? Icons.bookmark : Icons.bookmark_border), + title: Text( + isBookmarked + ? l10n.removeBookmarkActionLabel + : l10n.bookmarkActionLabel, + ), + onTap: () { + context.read().add( + AppBookmarkToggled( + headline: widget.headline, + isBookmarked: isBookmarked, + context: context, + ), + ); + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.share_outlined), + title: Text(l10n.shareActionLabel), + onTap: () { + // Pop the sheet before sharing to avoid it being open in the background. + Navigator.of(context).pop(); + Share.share(widget.headline.url); + }, + ), + if (isHeadlineReportingEnabled) + ListTile( + leading: const Icon(Icons.flag_outlined), + title: Text(l10n.reportActionLabel), + onTap: () async { + // Pop the current sheet before showing the new one. + Navigator.of(context).pop(); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ReportContentBottomSheet( + entityId: widget.headline.id, + reportableEntity: ReportableEntity.headline, + ), + ); + }, + ), + ], ); } } diff --git a/lib/user_content/app_review/services/app_review_service.dart b/lib/user_content/app_review/services/app_review_service.dart new file mode 100644 index 00000000..b72578a3 --- /dev/null +++ b/lib/user_content/app_review/services/app_review_service.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/config/app_environment.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/services/native_review_service.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/view/provide_feedback_bottom_sheet.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/app_review/view/rate_app_bottom_sheet.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; + +/// {@template app_review_service} +/// A service that encapsulates the business logic for the app review funnel. +/// +/// This service manages when and how to prompt the user for a review, +/// handles their responses, and interacts with the native review APIs. +/// {@endtemplate} +class AppReviewService { + /// {@macro app_review_service} + AppReviewService({ + required DataRepository appReviewRepository, + required NativeReviewService nativeReviewService, + Logger? logger, + }) : _appReviewRepository = appReviewRepository, + _nativeReviewService = nativeReviewService, + _logger = logger ?? Logger('AppReviewService'); + + final DataRepository _appReviewRepository; + final NativeReviewService _nativeReviewService; + final Logger _logger; + + /// Checks if the user is eligible for a review prompt and, if so, triggers it. + /// + /// This method is the main entry point for the review funnel. It should be + /// called after a positive user interaction (e.g., saving an article). + Future checkEligibilityAndTrigger({ + required BuildContext context, + required int positiveInteractionCount, + }) async { + final appState = context.read().state; + final user = appState.user; + final environment = context.read(); + final remoteConfig = appState.remoteConfig; + + if (user == null || remoteConfig == null) { + _logger.warning( + 'Cannot check eligibility: user or remoteConfig is null.', + ); + return; + } + + final communityConfig = remoteConfig.features.community; + // The app review feature should be disabled if the entire community + // feature set is disabled. + if (!communityConfig.enabled) { + _logger.fine( + 'App review feature is disabled because the parent ' + 'community feature is disabled.', + ); + return; + } + if (!communityConfig.appReview.enabled) { + _logger.fine('App review feature is disabled by its own config.'); + return; + } + + // Check if the user has already completed the rateApp decorator. + final decoratorStatus = user.feedDecoratorStatus[FeedDecoratorType.rateApp]; + if (decoratorStatus?.isCompleted == true) { + _logger.fine('User has already completed the review funnel.'); + return; + } + + final appReviewConfig = communityConfig.appReview; + // Check initial cooldown. + final daysSinceCreation = DateTime.now().difference(user.createdAt).inDays; + // For the demo environment, we ignore the initial cooldown period. + if (environment != AppEnvironment.demo) { + if (daysSinceCreation < appReviewConfig.initialPromptCooldownDays) { + _logger.fine( + 'User is within the initial cooldown period ($daysSinceCreation/${appReviewConfig.initialPromptCooldownDays} days).', + ); + return; + } + } + + // Check positive interaction threshold. + if (positiveInteractionCount == 0 || + positiveInteractionCount % appReviewConfig.interactionCycleThreshold != + 0) { + _logger.fine( + 'Interaction count ($positiveInteractionCount) does not meet threshold ' + 'cycle of ${appReviewConfig.interactionCycleThreshold}.', + ); + return; + } + + _logger.info('User is eligible for review prompt. Showing bottom sheet.'); + unawaited( + showModalBottomSheet( + context: context, + builder: (_) => RateAppBottomSheet( + onResponse: (isPositive) => _handleInitialPromptResponse( + context: context, + isPositive: isPositive, + ), + ), + ), + ); + } + + /// Handles the user's response from the initial "Enjoying the app?" prompt. + Future _handleInitialPromptResponse({ + required BuildContext context, + required bool isPositive, + }) async { + final appBloc = context.read(); + final userId = appBloc.state.user?.id; + if (userId == null) return; + + if (isPositive) { + _logger.info('User responded positively. Requesting native review.'); + + // Create the AppReview record first to log the positive interaction. + final review = AppReview( + id: const Uuid().v4(), + userId: userId, + feedback: AppReviewFeedback.positive, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + await _appReviewRepository.create(item: review, userId: userId); + + // Now, request the native review prompt. + final wasRequested = await _nativeReviewService.requestReview(); + + // If the OS-level prompt was successfully requested, update our record. + if (wasRequested) { + await _appReviewRepository.update( + id: review.id, + item: review.copyWith(wasStoreReviewRequested: true), + userId: userId, + ); + } + + // Mark the funnel as complete for this user, regardless of whether the + // OS showed the prompt, to respect the user's interaction. + appBloc.add( + AppUserFeedDecoratorShown( + userId: userId, + feedDecoratorType: FeedDecoratorType.rateApp, + isCompleted: true, + ), + ); + } else { + _logger.info('User responded negatively. Showing feedback sheet.'); + // Create the AppReview record first to log the negative interaction. + final review = AppReview( + id: const Uuid().v4(), + userId: userId, + feedback: AppReviewFeedback.negative, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + await _appReviewRepository.create(item: review, userId: userId); + + // Show the detailed feedback bottom sheet. + // Guard against using context across async gaps. + if (!context.mounted) return; + unawaited( + showModalBottomSheet( + context: context, + builder: (_) => ProvideFeedbackBottomSheet( + onFeedbackSubmitted: (details) => _handleNegativeFeedback( + reviewId: review.id, + userId: userId, + details: details, + ), + ), + ), + ); + } + } + + /// Handles the submission of detailed negative feedback. + Future _handleNegativeFeedback({ + required String reviewId, + required String userId, + required String details, + }) async { + _logger.info('User submitted negative feedback: "$details"'); + try { + // Read the existing review record that was created when the user + // responded "No". + final existingReview = await _appReviewRepository.read( + id: reviewId, + userId: userId, + ); + + // Update the existing record with the feedback details. + await _appReviewRepository.update( + id: reviewId, + item: existingReview.copyWith( + feedbackDetails: ValueWrapper(details), + updatedAt: DateTime.now(), + ), + userId: userId, + ); + _logger.fine('Negative feedback persisted for user $userId.'); + } catch (e, s) { + _logger.severe('Failed to persist negative feedback.', e, s); + } + } +} diff --git a/lib/user_content/app_review/services/native_review_service.dart b/lib/user_content/app_review/services/native_review_service.dart new file mode 100644 index 00000000..174519ae --- /dev/null +++ b/lib/user_content/app_review/services/native_review_service.dart @@ -0,0 +1,42 @@ +import 'package:in_app_review/in_app_review.dart'; +import 'package:logging/logging.dart'; + +/// {@template native_review_service} +/// An interface for handling native in-app review requests. +/// +/// This abstraction allows for different implementations, such as a real one +/// using the `in_app_review` package and a no-op one for testing or +/// unsupported platforms. +/// {@endtemplate} +abstract class NativeReviewService { + /// Requests the native in-app review prompt. + /// + /// Returns `true` if the request was made successfully, `false` otherwise. + /// This does not guarantee that the prompt was shown. + Future requestReview(); +} + +/// {@template in_app_review_service} +/// A concrete implementation of [NativeReviewService] that uses the +/// `in_app_review` package. +/// {@endtemplate} +class InAppReviewService implements NativeReviewService { + /// {@macro in_app_review_service} + InAppReviewService({required InAppReview inAppReview, Logger? logger}) + : _inAppReview = inAppReview, + _logger = logger ?? Logger('InAppReviewService'); + + final InAppReview _inAppReview; + final Logger _logger; + + @override + Future requestReview() async { + if (await _inAppReview.isAvailable()) { + _logger.info('Requesting native in-app review prompt.'); + await _inAppReview.requestReview(); + return true; + } + _logger.warning('Native in-app review is not available on this device.'); + return false; + } +} diff --git a/lib/user_content/app_review/view/provide_feedback_bottom_sheet.dart b/lib/user_content/app_review/view/provide_feedback_bottom_sheet.dart new file mode 100644 index 00000000..97fc6d8d --- /dev/null +++ b/lib/user_content/app_review/view/provide_feedback_bottom_sheet.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template provide_feedback_bottom_sheet} +/// A bottom sheet for collecting detailed user feedback after a negative +/// response in the app review funnel. +/// +/// It allows users to select from a list of predefined reasons or provide +/// custom text feedback. +/// {@endtemplate} +class ProvideFeedbackBottomSheet extends StatefulWidget { + /// {@macro provide_feedback_bottom_sheet} + const ProvideFeedbackBottomSheet({ + required this.onFeedbackSubmitted, + super.key, + }); + + /// Callback function that is triggered when the user submits their feedback. + final ValueChanged onFeedbackSubmitted; + + @override + State createState() => + _ProvideFeedbackBottomSheetState(); +} + +class _ProvideFeedbackBottomSheetState + extends State { + final _textController = TextEditingController(); + String? _selectedReason; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + void _submitFeedback() { + final feedbackDetails = _selectedReason == 'other' + ? _textController.text + : _selectedReason; + if (feedbackDetails != null && feedbackDetails.isNotEmpty) { + widget.onFeedbackSubmitted(feedbackDetails); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + final reasons = { + l10n.feedbackPromptReasonUI: 'ui_design', + l10n.feedbackPromptReasonPerformance: 'performance', + l10n.feedbackPromptReasonContent: 'content_quality', + l10n.feedbackPromptReasonOther: 'other', + }; + + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.feedbackPromptTitle, style: textTheme.headlineSmall), + const SizedBox(height: AppSpacing.lg), + ...reasons.entries.map((entry) { + return RadioListTile( + title: Text(entry.key), + value: entry.value, + groupValue: _selectedReason, + onChanged: (value) => setState(() => _selectedReason = value), + contentPadding: EdgeInsets.zero, + ); + }), + if (_selectedReason == 'other') + Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: TextFormField( + controller: _textController, + autofocus: true, + decoration: InputDecoration( + labelText: l10n.reportAdditionalCommentsLabel, + ), + maxLines: 3, + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancelButtonLabel), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: FilledButton( + onPressed: + (_selectedReason != null && + (_selectedReason != 'other' || + _textController.text.isNotEmpty)) + ? _submitFeedback + : null, + child: Text(l10n.feedbackPromptSubmitButton), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/user_content/app_review/view/rate_app_bottom_sheet.dart b/lib/user_content/app_review/view/rate_app_bottom_sheet.dart new file mode 100644 index 00000000..a40e49a0 --- /dev/null +++ b/lib/user_content/app_review/view/rate_app_bottom_sheet.dart @@ -0,0 +1,88 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template rate_app_bottom_sheet} +/// A bottom sheet that serves as the initial prompt in the app review funnel. +/// +/// It asks a simple "Yes/No" question to gauge user sentiment before +/// deciding whether to request a native store review. +/// {@endtemplate} +class RateAppBottomSheet extends StatelessWidget { + /// {@macro rate_app_bottom_sheet} + const RateAppBottomSheet({required this.onResponse, super.key}); + + /// Callback function that is triggered when the user taps "Yes" or "No". + /// The boolean parameter indicates if the response was positive. + final ValueChanged onResponse; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + // Determine which set of strings to use based on user's feedback history. + final hasGivenNegativeFeedback = + context + .read() + .state + .user + ?.feedDecoratorStatus[FeedDecoratorType.rateApp] + ?.lastShownAt != + null; + + final title = hasGivenNegativeFeedback + ? l10n.rateAppNegativeFollowUpTitle_1 + : l10n.rateAppPromptTitle; + final body = hasGivenNegativeFeedback + ? l10n.rateAppNegativeFollowUpBody_1 + : l10n.rateAppPromptBody; + + return Padding( + padding: const EdgeInsets.all(AppSpacing.paddingLarge), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.star_half, size: 48, color: colorScheme.primary), + const SizedBox(height: AppSpacing.lg), + Text( + title, + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.md), + Text(body, style: textTheme.bodyLarge, textAlign: TextAlign.center), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + onResponse(false); + }, + child: Text(l10n.rateAppPromptNoButton), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: FilledButton( + onPressed: () { + Navigator.of(context).pop(); + onResponse(true); + }, + child: Text(l10n.rateAppPromptYesButton), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/user_content/engagement/view/comments_bottom_sheet.dart b/lib/user_content/engagement/view/comments_bottom_sheet.dart new file mode 100644 index 00000000..dcad3813 --- /dev/null +++ b/lib/user_content/engagement/view/comments_bottom_sheet.dart @@ -0,0 +1,322 @@ +import 'package:collection/collection.dart'; +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/user_avatar.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/reporting/view/report_content_bottom_sheet.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template comments_bottom_sheet} +/// A bottom sheet that displays comments for a headline and allows users +/// to post new comments. +/// {@endtemplate} +class CommentsBottomSheet extends StatelessWidget { + /// {@macro comments_bottom_sheet} + const CommentsBottomSheet({required this.headlineId, super.key}); + + /// The ID of the headline for which comments are being displayed. + final String headlineId; + + @override + Widget build(BuildContext context) { + // Provide the HeadlinesFeedBloc to the view. + return BlocProvider.value( + value: context.read(), + child: _CommentsBottomSheetView(headlineId: headlineId), + ); + } +} + +class _CommentsBottomSheetView extends StatefulWidget { + const _CommentsBottomSheetView({required this.headlineId}); + // A key to manage the state of the input field, allowing parent widgets + // to trigger actions like editing. + static final _inputFieldKey = GlobalKey<__CommentInputFieldState>(); + + final String headlineId; + + @override + State<_CommentsBottomSheetView> createState() => + __CommentsBottomSheetViewState(); +} + +class __CommentsBottomSheetViewState extends State<_CommentsBottomSheetView> { + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + builder: (context, sheetScrollController) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Text( + l10n.commentsPageTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Divider(height: 1), + Expanded(child: _buildContent(context, sheetScrollController)), + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: _CommentInputField( + key: _CommentsBottomSheetView._inputFieldKey, + headlineId: widget.headlineId, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildContent( + BuildContext context, + ScrollController scrollController, + ) { + return BlocBuilder( + builder: (context, state) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + + final user = context.select((AppBloc bloc) => bloc.state.user); + final currentLocale = context.watch().state.locale; + + final engagements = state.engagementsMap[widget.headlineId] ?? []; + final comments = engagements.where((e) => e.comment != null).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + if (state.status == HeadlinesFeedStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (comments.isEmpty) { + return Center( + child: Text( + l10n.noCommentsYet, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.separated( + controller: scrollController, + itemCount: comments.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final engagement = comments[index]; + final comment = engagement.comment!; + final formattedDate = timeago.format( + engagement.updatedAt, + locale: currentLocale.languageCode, + ); + + final isOwnComment = user != null && engagement.userId == user.id; + + return ListTile( + leading: UserAvatar(user: user), + title: Row( + children: [ + Text( + l10n.commenterName(engagement.userId.substring(0, 4)), + style: theme.textTheme.titleSmall, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + '• $formattedDate', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isOwnComment) + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20), + onPressed: () { + _CommentsBottomSheetView._inputFieldKey.currentState + ?.startEditing(); + }, + ), + IconButton( + icon: const Icon(Icons.flag_outlined, size: 20), + onPressed: () { + Navigator.of(context).pop(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ReportContentBottomSheet( + entityId: engagement.id, + reportableEntity: ReportableEntity.comment, + ), + ); + }, + ), + ], + ), + subtitle: Text(comment.content), + ); + }, + ); + }, + ); + } +} + +class _CommentInputField extends StatefulWidget { + const _CommentInputField({required this.headlineId, super.key}); + + final String headlineId; + + @override + State<_CommentInputField> createState() => __CommentInputFieldState(); +} + +class __CommentInputFieldState extends State<_CommentInputField> { + final _focusNode = FocusNode(); + final _controller = TextEditingController(); + bool _isEditing = false; + + @override + void initState() { + super.initState(); + _controller.addListener(() { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void dispose() { + _focusNode.dispose(); + _controller.dispose(); + super.dispose(); + } + + void startEditing() { + final user = context.read().state.user; + if (user == null) return; + + final engagements = + context + .read() + .state + .engagementsMap[widget.headlineId] ?? + []; + final userEngagement = engagements.firstWhereOrNull( + (e) => e.userId == user.id, + ); + final existingComment = userEngagement?.comment?.content; + + if (existingComment != null) { + setState(() { + _controller.text = existingComment; + _isEditing = true; + }); + _focusNode.requestFocus(); + } + } + + void resetAfterSubmit() { + _controller.clear(); + if (mounted) { + setState(() => _isEditing = false); + } + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final user = context.select((AppBloc bloc) => bloc.state.user); + final isGuest = user?.appRole == AppUserRole.guestUser; + + return BlocBuilder( + builder: (context, state) { + final engagements = state.engagementsMap[widget.headlineId] ?? []; + final userEngagement = engagements.firstWhereOrNull( + (e) => e.userId == user?.id, + ); + final hasExistingComment = userEngagement?.comment != null; + final isEnabled = !isGuest && (!hasExistingComment || _isEditing); + + final canPost = _controller.text.isNotEmpty; + + return Row( + children: [ + Expanded( + child: TextFormField( + focusNode: _focusNode, + controller: _controller, + enabled: isEnabled, + decoration: InputDecoration( + hintText: isEnabled + ? (_isEditing + ? l10n.commentEditButtonLabel + : l10n.commentInputHint) + : (isGuest + ? l10n.commentInputDisabledHint + : l10n.commentInputExistingHint), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + IconButton( + icon: Icon(_isEditing ? Icons.check : Icons.send), + color: canPost ? theme.colorScheme.primary : null, + tooltip: _isEditing + ? l10n.commentEditButtonLabel + : l10n.commentPostButtonLabel, + onPressed: canPost && isEnabled + ? () { + if (_isEditing) { + context.read().add( + HeadlinesFeedCommentUpdated( + widget.headlineId, + _controller.text, + context: context, + ), + ); + } else { + context.read().add( + HeadlinesFeedCommentPosted( + widget.headlineId, + _controller.text, + context: context, + ), + ); + } + resetAfterSubmit(); + } + : null, + ), + ], + ); + }, + ); + } +} diff --git a/lib/user_content/engagement/widgets/headline_actions_row.dart b/lib/user_content/engagement/widgets/headline_actions_row.dart new file mode 100644 index 00000000..d1be430f --- /dev/null +++ b/lib/user_content/engagement/widgets/headline_actions_row.dart @@ -0,0 +1,141 @@ +import 'package:collection/collection.dart'; +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/bloc/headlines_feed_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/engagement/view/comments_bottom_sheet.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/user_content/engagement/widgets/inline_reaction_selector.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template headline_actions_row} +/// A widget that displays a row of engagement actions for a headline tile. +/// This includes the inline reaction selector and a button to view comments. +/// {@endtemplate} +class HeadlineActionsRow extends StatelessWidget { + /// {@macro headline_actions_row} + const HeadlineActionsRow({ + required this.headline, + required this.engagements, + super.key, + }); + + /// The headline for which to display actions. + final Headline headline; + + /// The list of engagements for this headline. + final List engagements; + + @override + Widget build(BuildContext context) { + return _HeadlineActionsRowView( + headline: headline, + engagements: engagements, + ); + } +} + +class _HeadlineActionsRowView extends StatelessWidget { + const _HeadlineActionsRowView({ + required this.headline, + required this.engagements, + }); + + final Headline headline; + final List engagements; + + @override + Widget build(BuildContext context) { + final remoteConfig = context.select( + (AppBloc bloc) => bloc.state.remoteConfig, + ); + final communityConfig = remoteConfig?.features.community; + + // If the community feature is disabled, show nothing. + if (communityConfig?.enabled != true) { + return const SizedBox.shrink(); + } + + final isCommentingEnabled = + communityConfig!.engagement.engagementMode == + EngagementMode.reactionsAndComments; + + final userId = context.select((AppBloc bloc) => bloc.state.user?.id); + final userEngagement = engagements.firstWhereOrNull( + (e) => e.userId == userId, + ); + final userReaction = userEngagement?.reaction?.reactionType; + final commentCount = engagements.where((e) => e.comment != null).length; + + final theme = Theme.of(context); + final mutedColor = theme.colorScheme.onSurfaceVariant.withOpacity(0.6); + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: InlineReactionSelector( + unselectedColor: mutedColor, + selectedReaction: userReaction, + onReactionSelected: (reaction) => + _onReactionSelected(context, reaction), + ), + ), + if (isCommentingEnabled) + _CommentsButton( + commentCount: commentCount, + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => CommentsBottomSheet(headlineId: headline.id), + ), + ), + ], + ), + ); + } + + void _onReactionSelected(BuildContext context, ReactionType? reaction) { + context.read().add( + HeadlinesFeedReactionUpdated(headline.id, reaction, context: context), + ); + } +} + +class _CommentsButton extends StatelessWidget { + const _CommentsButton({required this.commentCount, this.onPressed}); + + final int commentCount; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final colorScheme = theme.colorScheme; + + final mutedTextStyle = textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant.withOpacity(0.6), + ); + + return TextButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.chat_bubble_outline, size: 16), + label: Text( + commentCount > 0 + ? l10n.commentsCount(commentCount) + : l10n.commentActionLabel, + ), + style: TextButton.styleFrom( + // Apply the muted color to both the icon and the text. + foregroundColor: mutedTextStyle?.color, + // Apply the text style for font size and weight. + textStyle: mutedTextStyle, + ), + ); + } +} diff --git a/lib/user_content/engagement/widgets/inline_reaction_selector.dart b/lib/user_content/engagement/widgets/inline_reaction_selector.dart new file mode 100644 index 00000000..dbac9ce2 --- /dev/null +++ b/lib/user_content/engagement/widgets/inline_reaction_selector.dart @@ -0,0 +1,91 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template inline_reaction_selector} +/// A row of reaction icons designed for inline placement within a feed tile. +/// +/// This widget is intentionally subtle to not distract from the main content. +/// {@endtemplate} +class InlineReactionSelector extends StatelessWidget { + /// {@macro inline_reaction_selector} + const InlineReactionSelector({ + this.selectedReaction, + this.onReactionSelected, + this.unselectedColor, + super.key, + }); + + /// The currently selected reaction, if any. + final ReactionType? selectedReaction; + + /// The color for unselected reaction icons. + final Color? unselectedColor; + + /// Callback for when a reaction is selected. + final ValueChanged? onReactionSelected; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: List.generate(ReactionType.values.length, (index) { + final reaction = ReactionType.values[index]; + final isSelected = selectedReaction == reaction; + return Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: _ReactionIcon( + reaction: reaction, + isSelected: isSelected, + unselectedColor: unselectedColor, + onTap: () => onReactionSelected?.call(isSelected ? null : reaction), + ), + ); + }), + ); + } +} + +class _ReactionIcon extends StatelessWidget { + const _ReactionIcon({ + required this.reaction, + required this.isSelected, + required this.onTap, + this.unselectedColor, + }); + + final ReactionType reaction; + final bool isSelected; + final Color? unselectedColor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final iconData = switch (reaction) { + ReactionType.like => Icons.thumb_up_outlined, + ReactionType.insightful => Icons.lightbulb_outline, + ReactionType.amusing => Icons.sentiment_satisfied_outlined, + ReactionType.sad => Icons.sentiment_dissatisfied_outlined, + ReactionType.angry => Icons.local_fire_department_outlined, + ReactionType.skeptical => Icons.thumb_down_outlined, + }; + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + child: Icon( + iconData, + color: isSelected + ? colorScheme.primary + : unselectedColor ?? colorScheme.onSurfaceVariant, + size: 22, + semanticLabel: reaction.name, + ), + ), + ); + } +} diff --git a/lib/user_content/reporting/view/report_content_bottom_sheet.dart b/lib/user_content/reporting/view/report_content_bottom_sheet.dart new file mode 100644 index 00000000..82ef558d --- /dev/null +++ b/lib/user_content/reporting/view/report_content_bottom_sheet.dart @@ -0,0 +1,242 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:logging/logging.dart'; +import 'package:ui_kit/ui_kit.dart'; +import 'package:uuid/uuid.dart'; + +/// {@template report_content_bottom_sheet} +/// A bottom sheet for reporting content such as headlines, sources, or comments. +/// +/// This widget implements a multi-step process: +/// 1. User selects a reason for the report. +/// 2. User can add optional comments. +/// 3. The report is submitted to the backend. +/// {@endtemplate} +class ReportContentBottomSheet extends StatefulWidget { + /// {@macro report_content_bottom_sheet} + const ReportContentBottomSheet({ + required this.entityId, + required this.reportableEntity, + super.key, + }); + + /// The ID of the entity being reported. + final String entityId; + + /// The type of entity being reported. + final ReportableEntity reportableEntity; + + @override + State createState() => + _ReportContentBottomSheetState(); +} + +class _ReportContentBottomSheetState extends State { + final _logger = Logger('ReportContentBottomSheet'); + final _textController = TextEditingController(); + String? _selectedReason; + bool _isSubmitting = false; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Future _submitReport() async { + final userId = context.read().state.user?.id; + if (userId == null || _selectedReason == null) return; + + final report = Report( + id: const Uuid().v4(), + reporterUserId: userId, + entityId: widget.entityId, + entityType: widget.reportableEntity, + reason: _selectedReason!, + additionalComments: _textController.text.isNotEmpty + ? _textController.text + : null, + status: ModerationStatus.pendingReview, + createdAt: DateTime.now(), + ); + + try { + setState(() => _isSubmitting = true); + context.read().add(AppContentReported(report: report)); + + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).reportSuccessSnackbar), + ), + ); + Navigator.of(context).pop(); + } + } on Exception catch (e, s) { + _logger.severe('Failed to submit report', e, s); + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + AppLocalizationsX(context).l10n.reportFailureSnackbar, + ), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + Map _getReasons(AppLocalizations l10n) { + switch (widget.reportableEntity) { + case ReportableEntity.headline: + return HeadlineReportReason.values.asNameMap().map( + (key, value) => MapEntry(value.toL10n(l10n), key), + ); + case ReportableEntity.source: + return SourceReportReason.values.asNameMap().map( + (key, value) => MapEntry(value.toL10n(l10n), key), + ); + case ReportableEntity.comment: + return CommentReportReason.values.asNameMap().map( + (key, value) => MapEntry(value.toL10n(l10n), key), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final reasons = _getReasons(l10n); + + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg + MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.reportContentTitle, style: textTheme.headlineSmall), + const SizedBox(height: AppSpacing.md), + Text( + l10n.reportReasonSelectionPrompt, + style: textTheme.bodyLarge, + ), + const SizedBox(height: AppSpacing.md), + ...reasons.entries.map((entry) { + return RadioListTile( + title: Text(entry.key), + value: entry.value, + groupValue: _selectedReason, + onChanged: (value) => setState(() => _selectedReason = value), + contentPadding: EdgeInsets.zero, + ); + }), + const SizedBox(height: AppSpacing.md), + TextFormField( + controller: _textController, + decoration: InputDecoration( + labelText: l10n.reportAdditionalCommentsLabel, + ), + maxLines: 3, + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancelButtonLabel), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: FilledButton( + onPressed: _selectedReason != null && !_isSubmitting + ? _submitReport + : null, + child: _isSubmitting + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : Text(l10n.reportSubmitButtonLabel), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +extension on HeadlineReportReason { + String toL10n(AppLocalizations l10n) { + switch (this) { + case HeadlineReportReason.misinformationOrFakeNews: + return l10n.headlineReportReasonMisinformation; + case HeadlineReportReason.clickbaitTitle: + return l10n.headlineReportReasonClickbait; + case HeadlineReportReason.offensiveOrHateSpeech: + return l10n.headlineReportReasonOffensive; + case HeadlineReportReason.spamOrScam: + return l10n.headlineReportReasonSpam; + case HeadlineReportReason.brokenLink: + return l10n.headlineReportReasonBrokenLink; + case HeadlineReportReason.paywalled: + return l10n.headlineReportReasonPaywalled; + } + } +} + +extension on SourceReportReason { + String toL10n(AppLocalizations l10n) { + switch (this) { + case SourceReportReason.lowQualityJournalism: + return l10n.sourceReportReasonLowQuality; + case SourceReportReason.highAdDensity: + return l10n.sourceReportReasonHighAdDensity; + case SourceReportReason.frequentPaywalls: + return l10n.sourceReportReasonFrequentPaywalls; + case SourceReportReason.impersonation: + return l10n.sourceReportReasonImpersonation; + case SourceReportReason.spreadsMisinformation: + return l10n.sourceReportReasonMisinformation; + } + } +} + +extension on CommentReportReason { + String toL10n(AppLocalizations l10n) { + switch (this) { + case CommentReportReason.spamOrAdvertising: + return l10n.commentReportReasonSpam; + case CommentReportReason.harassmentOrBullying: + return l10n.commentReportReasonHarassment; + case CommentReportReason.hateSpeech: + return l10n.commentReportReasonHateSpeech; + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 4399bbd4..13cee140 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,8 +185,8 @@ packages: dependency: "direct main" description: path: "." - ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" - resolved-ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" + ref: "8e02a696ee8921c68160cd15bd1c55effb5212a7" + resolved-ref: "8e02a696ee8921c68160cd15bd1c55effb5212a7" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" @@ -615,6 +615,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + in_app_review: + dependency: "direct main" + description: + name: in_app_review + sha256: ab26ac54dbd802896af78c670b265eaeab7ecddd6af4d0751e9604b60574817f + url: "https://pub.dev" + source: hosted + version: "2.0.11" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 + url: "https://pub.dev" + source: hosted + version: "2.0.5" intl: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6838ba66..dc495dd1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git ref: v1.0.1 + in_app_review: ^2.0.11 intl: ^0.20.2 js_interop: ^0.0.1 kv_storage_service: @@ -146,7 +147,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: 3779a8b1dbd8450d524574cf5376b7cc2ed514e7 + ref: 8e02a696ee8921c68160cd15bd1c55effb5212a7 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git