diff --git a/README.md b/README.md index 2335e7b0..2d93376f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,18 @@ Effortlessly manage your entire user base with a dedicated user management syste +
+💬 Community & Moderation Control + +### 💬 Comprehensive Moderation Hub +Directly manage all user-generated content from a centralized command center. Review, moderate, and act on user interactions to maintain a healthy and constructive community environment. +- **Unified Content Review:** Seamlessly moderate all incoming user engagements (reactions and comments), content reports, and app review feedback from a single, intuitive interface. +- **Streamlined Moderation Workflow:** Quickly approve or reject comments, resolve user-submitted reports, and analyze feedback with a consistent set of tools designed for rapid decision-making. +- **Direct User Insight:** Gain a clear, unfiltered view of user sentiment, content issues, and overall satisfaction by directly engaging with their feedback and reports. +> **Your Advantage:** Foster a positive community, protect your brand by quickly addressing problematic content, and gather valuable user insights to improve your content strategy, all from one integrated hub. + +
+
⚙️ App Monetization & Remote Control @@ -62,6 +74,7 @@ Dynamically control the mobile app's behavior and operational state directly fro - **Critical State Management:** Instantly activate a maintenance mode or enforce a mandatory app update for your users to handle operational issues or critical releases gracefully. - **Dynamic In-App Content:** Remotely manage the visibility and behavior of in-feed promotional prompts and user engagement elements. - **Tier-Based Feature Gating:** Define and enforce feature limits based on user roles, such as setting the maximum number of followed topics or saved headlines for different subscription levels. +- **Full Community Feature Control:** Remotely enable or disable the entire user engagement system (reactions, comments), the content reporting feature, and the in-app review funnel. Fine-tune engagement modes and configure rules for when and how users are prompted for feedback. - **Global Notification Control:** Remotely enable or disable the entire push notification system, switch between providers (e.g., Firebase, OneSignal), and toggle specific delivery types like breaking news or daily digests. > **Your Advantage:** Gain unparalleled agility to manage your live application. Ensure service stability, drive user actions, and configure business rules instantly, all from a centralized control panel. diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 764c93e9..a724e64b 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -11,6 +11,8 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app/bloc/app_blo import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/app_environment.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/bloc/app_configuration_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/bloc/authentication_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; @@ -19,6 +21,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localiz import 'package:flutter_news_app_web_dashboard_full_source_code/overview/bloc/overview_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_updates_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart'; @@ -41,6 +44,9 @@ class App extends StatelessWidget { required DataRepository countriesRepository, required DataRepository languagesRepository, required DataRepository usersRepository, + required DataRepository engagementsRepository, + required DataRepository reportsRepository, + required DataRepository appReviewsRepository, required KVStorageService storageService, required AppEnvironment environment, required PendingDeletionsService pendingDeletionsService, @@ -57,6 +63,9 @@ class App extends StatelessWidget { _countriesRepository = countriesRepository, _languagesRepository = languagesRepository, _usersRepository = usersRepository, + _engagementsRepository = engagementsRepository, + _reportsRepository = reportsRepository, + _appReviewsRepository = appReviewsRepository, _environment = environment, _pendingDeletionsService = pendingDeletionsService; @@ -72,6 +81,9 @@ class App extends StatelessWidget { final DataRepository _countriesRepository; final DataRepository _languagesRepository; final DataRepository _usersRepository; + final DataRepository _engagementsRepository; + final DataRepository _reportsRepository; + final DataRepository _appReviewsRepository; final KVStorageService _kvStorageService; final AppEnvironment _environment; @@ -93,6 +105,9 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _countriesRepository), RepositoryProvider.value(value: _languagesRepository), RepositoryProvider.value(value: _usersRepository), + RepositoryProvider.value(value: _engagementsRepository), + RepositoryProvider.value(value: _reportsRepository), + RepositoryProvider.value(value: _appReviewsRepository), RepositoryProvider.value(value: _kvStorageService), RepositoryProvider( create: (context) => const ThrottledFetchingService(), @@ -100,6 +115,9 @@ class App extends StatelessWidget { RepositoryProvider.value( value: _pendingDeletionsService, ), + RepositoryProvider( + create: (context) => PendingUpdatesServiceImpl(), + ), ], child: MultiBlocProvider( providers: [ @@ -163,6 +181,18 @@ class App extends StatelessWidget { userFilterBloc: context.read(), ), ), + BlocProvider( + create: (context) => CommunityFilterBloc(), + ), + BlocProvider( + create: (context) => CommunityManagementBloc( + engagementsRepository: context.read>(), + reportsRepository: context.read>(), + appReviewsRepository: context.read>(), + communityFilterBloc: context.read(), + pendingUpdatesService: context.read(), + ), + ), ], child: _AppView( authenticationRepository: _authenticationRepository, diff --git a/lib/app/view/app_shell.dart b/lib/app/view/app_shell.dart index 88ea06e9..156ef59e 100644 --- a/lib/app/view/app_shell.dart +++ b/lib/app/view/app_shell.dart @@ -44,12 +44,17 @@ class AppShell extends StatelessWidget { NavigationDestination( icon: const Icon(Icons.folder_open_outlined), selectedIcon: const Icon(Icons.folder), - label: l10n.contentManagement, + label: l10n.navContent, ), NavigationDestination( icon: const Icon(Icons.people_outline), selectedIcon: const Icon(Icons.people), - label: l10n.userManagement, + label: l10n.navUsers, + ), + NavigationDestination( + icon: const Icon(Icons.forum_outlined), + selectedIcon: const Icon(Icons.forum), + label: l10n.navCommunity, ), NavigationDestination( icon: const Icon(Icons.settings_applications_outlined), @@ -64,6 +69,7 @@ class AppShell extends StatelessWidget { Routes.overviewName, Routes.contentManagementName, Routes.userManagementName, + Routes.communityManagementName, Routes.appConfigurationName, ]; diff --git a/lib/app_configuration/widgets/app_review_settings_form.dart b/lib/app_configuration/widgets/app_review_settings_form.dart index 2b058366..9498d03b 100644 --- a/lib/app_configuration/widgets/app_review_settings_form.dart +++ b/lib/app_configuration/widgets/app_review_settings_form.dart @@ -107,10 +107,12 @@ class _AppReviewSettingsFormState extends State { padding: const EdgeInsetsDirectional.only( start: AppSpacing.lg, ), - child: Column( - children: [ - ExpansionTile( + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return ExpansionTile( title: Text(l10n.internalPromptLogicTitle), + initiallyExpanded: !isMobile, childrenPadding: const EdgeInsetsDirectional.only( start: AppSpacing.lg, top: AppSpacing.md, @@ -162,10 +164,20 @@ class _AppReviewSettingsFormState extends State { controller: _initialPromptCooldownController, ), ], - ), - const SizedBox(height: AppSpacing.lg), - ExpansionTile( + ); + }, + ), + ), + Padding( + padding: const EdgeInsetsDirectional.only( + start: AppSpacing.lg, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return ExpansionTile( title: Text(l10n.followUpActionsTitle), + initiallyExpanded: !isMobile, childrenPadding: const EdgeInsetsDirectional.only( start: AppSpacing.lg, top: AppSpacing.md, @@ -220,8 +232,8 @@ class _AppReviewSettingsFormState extends State { }, ), ], - ), - ], + ); + }, ), ), ], diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 962ca7d4..f2b1507a 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -65,6 +65,9 @@ Future bootstrap( DataClient countriesClient; DataClient languagesClient; DataClient usersClient; + DataClient engagementsClient; + DataClient reportsClient; + DataClient appReviewsClient; if (appConfig.environment == app_config.AppEnvironment.demo) { headlinesClient = DataInMemory( @@ -128,6 +131,24 @@ Future bootstrap( initialData: usersFixturesData, logger: Logger('DataInMemory'), ); + engagementsClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: getEngagementsFixturesData(), + logger: Logger('DataInMemory'), + ); + reportsClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: getReportsFixturesData(), + logger: Logger('DataInMemory'), + ); + appReviewsClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: getAppReviewsFixturesData(), + logger: Logger('DataInMemory'), + ); } else { headlinesClient = DataApi( httpClient: httpClient!, @@ -200,6 +221,27 @@ Future bootstrap( toJson: (user) => user.toJson(), logger: Logger('DataApi'), ); + engagementsClient = DataApi( + httpClient: httpClient, + modelName: 'engagement', + fromJson: Engagement.fromJson, + toJson: (engagement) => engagement.toJson(), + logger: Logger('DataApi'), + ); + reportsClient = DataApi( + httpClient: httpClient, + modelName: 'report', + fromJson: Report.fromJson, + toJson: (report) => report.toJson(), + logger: Logger('DataApi'), + ); + appReviewsClient = DataApi( + httpClient: httpClient, + modelName: 'app_review', + fromJson: AppReview.fromJson, + toJson: (appReview) => appReview.toJson(), + logger: Logger('DataApi'), + ); } pendingDeletionsService = PendingDeletionsServiceImpl( @@ -231,6 +273,15 @@ Future bootstrap( dataClient: languagesClient, ); final usersRepository = DataRepository(dataClient: usersClient); + final engagementsRepository = DataRepository( + dataClient: engagementsClient, + ); + final reportsRepository = DataRepository( + dataClient: reportsClient, + ); + final appReviewsRepository = DataRepository( + dataClient: appReviewsClient, + ); return App( authenticationRepository: authenticationRepository, @@ -244,6 +295,9 @@ Future bootstrap( countriesRepository: countriesRepository, languagesRepository: languagesRepository, usersRepository: usersRepository, + engagementsRepository: engagementsRepository, + reportsRepository: reportsRepository, + appReviewsRepository: appReviewsRepository, storageService: kvStorage, environment: environment, pendingDeletionsService: pendingDeletionsService, diff --git a/lib/community_management/bloc/community_filter/community_filter_bloc.dart b/lib/community_management/bloc/community_filter/community_filter_bloc.dart new file mode 100644 index 00000000..47ce4b33 --- /dev/null +++ b/lib/community_management/bloc/community_filter/community_filter_bloc.dart @@ -0,0 +1,52 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:equatable/equatable.dart'; + +part 'community_filter_event.dart'; +part 'community_filter_state.dart'; + +class CommunityFilterBloc + extends Bloc { + CommunityFilterBloc() : super(const CommunityFilterState()) { + on( + (event, emit) => emit( + state.copyWith( + engagementsFilter: event.filter, + version: state.version, + ), + ), + ); + on( + (event, emit) => emit( + state.copyWith( + reportsFilter: event.filter, + version: state.version, + ), + ), + ); + on( + (event, emit) => emit( + state.copyWith( + appReviewsFilter: event.filter, + version: state.version, + ), + ), + ); + on(_onFilterReset); + on(_onFilterApplied); + } + + void _onFilterReset( + CommunityFilterReset event, + Emitter emit, + ) { + emit(const CommunityFilterState()); + } + + void _onFilterApplied( + CommunityFilterApplied event, + Emitter emit, + ) { + emit(state.copyWith(version: state.version + 1)); + } +} diff --git a/lib/community_management/bloc/community_filter/community_filter_event.dart b/lib/community_management/bloc/community_filter/community_filter_event.dart new file mode 100644 index 00000000..2e661254 --- /dev/null +++ b/lib/community_management/bloc/community_filter/community_filter_event.dart @@ -0,0 +1,43 @@ +part of 'community_filter_bloc.dart'; + +abstract class CommunityFilterEvent extends Equatable { + const CommunityFilterEvent(); + + @override + List get props => []; +} + +class EngagementsFilterChanged extends CommunityFilterEvent { + const EngagementsFilterChanged(this.filter); + + final EngagementsFilter filter; + + @override + List get props => [filter]; +} + +class ReportsFilterChanged extends CommunityFilterEvent { + const ReportsFilterChanged(this.filter); + + final ReportsFilter filter; + + @override + List get props => [filter]; +} + +class AppReviewsFilterChanged extends CommunityFilterEvent { + const AppReviewsFilterChanged(this.filter); + + final AppReviewsFilter filter; + + @override + List get props => [filter]; +} + +class CommunityFilterApplied extends CommunityFilterEvent { + const CommunityFilterApplied(); +} + +class CommunityFilterReset extends CommunityFilterEvent { + const CommunityFilterReset(); +} diff --git a/lib/community_management/bloc/community_filter/community_filter_state.dart b/lib/community_management/bloc/community_filter/community_filter_state.dart new file mode 100644 index 00000000..e973575d --- /dev/null +++ b/lib/community_management/bloc/community_filter/community_filter_state.dart @@ -0,0 +1,95 @@ +part of 'community_filter_bloc.dart'; + +class EngagementsFilter extends Equatable { + const EngagementsFilter({ + this.searchQuery, + this.selectedStatus, + }); + + final String? searchQuery; + final ModerationStatus? selectedStatus; + + bool get isFilterActive => + (searchQuery != null && searchQuery!.isNotEmpty) || + selectedStatus != null; + + @override + List get props => [searchQuery, selectedStatus]; +} + +class ReportsFilter extends Equatable { + const ReportsFilter({ + this.searchQuery, + this.selectedStatus, + this.selectedReportableEntity, + }); + + final String? searchQuery; + final ModerationStatus? selectedStatus; + final ReportableEntity? selectedReportableEntity; + + bool get isFilterActive => + (searchQuery != null && searchQuery!.isNotEmpty) || + selectedStatus != null || + selectedReportableEntity != null; + + @override + List get props => [ + searchQuery, + selectedStatus, + selectedReportableEntity, + ]; +} + +class AppReviewsFilter extends Equatable { + const AppReviewsFilter({ + this.searchQuery, + this.selectedFeedback, + }); + + final String? searchQuery; + final AppReviewFeedback? selectedFeedback; + + bool get isFilterActive => + (searchQuery != null && searchQuery!.isNotEmpty) || + selectedFeedback != null; + + @override + List get props => [searchQuery, selectedFeedback]; +} + +class CommunityFilterState extends Equatable { + const CommunityFilterState({ + this.engagementsFilter = const EngagementsFilter(), + this.reportsFilter = const ReportsFilter(), + this.appReviewsFilter = const AppReviewsFilter(), + this.version = 0, + }); + + final int version; + final EngagementsFilter engagementsFilter; + final ReportsFilter reportsFilter; + final AppReviewsFilter appReviewsFilter; + + CommunityFilterState copyWith({ + EngagementsFilter? engagementsFilter, + ReportsFilter? reportsFilter, + AppReviewsFilter? appReviewsFilter, + int? version, + }) { + return CommunityFilterState( + engagementsFilter: engagementsFilter ?? this.engagementsFilter, + reportsFilter: reportsFilter ?? this.reportsFilter, + appReviewsFilter: appReviewsFilter ?? this.appReviewsFilter, + version: version ?? this.version, + ); + } + + @override + List get props => [ + engagementsFilter, + reportsFilter, + appReviewsFilter, + version, + ]; +} diff --git a/lib/community_management/bloc/community_management_bloc.dart b/lib/community_management/bloc/community_management_bloc.dart new file mode 100644 index 00000000..bb41ad7c --- /dev/null +++ b/lib/community_management/bloc/community_management_bloc.dart @@ -0,0 +1,473 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_updates_service.dart'; +import 'package:logging/logging.dart'; + +part 'community_management_event.dart'; +part 'community_management_state.dart'; + +class CommunityManagementBloc + extends Bloc { + CommunityManagementBloc({ + required DataRepository engagementsRepository, + required DataRepository reportsRepository, + required DataRepository appReviewsRepository, + required CommunityFilterBloc communityFilterBloc, + required PendingUpdatesService pendingUpdatesService, + Logger? logger, + }) : _engagementsRepository = engagementsRepository, + _reportsRepository = reportsRepository, + _appReviewsRepository = appReviewsRepository, + _communityFilterBloc = communityFilterBloc, + _pendingUpdatesService = pendingUpdatesService, + _logger = logger ?? Logger('CommunityManagementBloc'), + super(const CommunityManagementState()) { + on(_onTabChanged); + on(_onLoadEngagementsRequested); + on(_onLoadReportsRequested); + on(_onLoadAppReviewsRequested); + on(_onApproveCommentRequested); + on(_onRejectCommentRequested); + on(_onResolveReportRequested); + on(_onUndoUpdateRequested); + on(_onUpdateEventReceived); + + _engagementsUpdateSubscription = _engagementsRepository.entityUpdated + .listen((_) { + _logger.info('Engagement updated, reloading engagements list.'); + add( + LoadEngagementsRequested( + filter: buildEngagementsFilterMap( + _communityFilterBloc.state.engagementsFilter, + ), + forceRefresh: true, + ), + ); + }); + + _reportsUpdateSubscription = _reportsRepository.entityUpdated.listen((_) { + _logger.info('Report updated, reloading reports list.'); + add( + LoadReportsRequested( + filter: buildReportsFilterMap( + _communityFilterBloc.state.reportsFilter, + ), + forceRefresh: true, + ), + ); + }); + + _appReviewsUpdateSubscription = _appReviewsRepository.entityUpdated.listen(( + _, + ) { + _logger.info('AppReview updated, reloading app reviews list.'); + add( + LoadAppReviewsRequested( + filter: buildAppReviewsFilterMap( + _communityFilterBloc.state.appReviewsFilter, + ), + forceRefresh: true, + ), + ); + }); + + _updateEventsSubscription = _pendingUpdatesService.updateEvents.listen( + (event) => add(UpdateEventReceived(event)), + ); + } + + final DataRepository _engagementsRepository; + final DataRepository _reportsRepository; + final DataRepository _appReviewsRepository; + final CommunityFilterBloc _communityFilterBloc; + final Logger _logger; + final PendingUpdatesService _pendingUpdatesService; + + late final StreamSubscription _engagementsUpdateSubscription; + late final StreamSubscription _reportsUpdateSubscription; + late final StreamSubscription _appReviewsUpdateSubscription; + late final StreamSubscription> _updateEventsSubscription; + + @override + Future close() { + _engagementsUpdateSubscription.cancel(); + _reportsUpdateSubscription.cancel(); + _appReviewsUpdateSubscription.cancel(); + _updateEventsSubscription.cancel(); + return super.close(); + } + + Map buildEngagementsFilterMap(EngagementsFilter filter) { + final filterMap = {}; + if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) { + filterMap['userId'] = filter.searchQuery; + } + if (filter.selectedStatus != null) { + filterMap['comment.status'] = { + r'$in': [filter.selectedStatus!.name], + }; + } + return filterMap; + } + + Map buildReportsFilterMap(ReportsFilter filter) { + final filterMap = {}; + if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) { + filterMap['reporterUserId'] = filter.searchQuery; + } + if (filter.selectedStatus != null) { + filterMap['status'] = { + r'$in': [filter.selectedStatus!.name], + }; + } + if (filter.selectedReportableEntity != null) { + filterMap['entityType'] = { + r'$in': [filter.selectedReportableEntity!.name], + }; + } + return filterMap; + } + + Map buildAppReviewsFilterMap(AppReviewsFilter filter) { + final filterMap = {}; + if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) { + filterMap['userId'] = filter.searchQuery; + } + if (filter.selectedFeedback != null) { + filterMap['feedback'] = { + r'$in': [filter.selectedFeedback!.name], + }; + } + return filterMap; + } + + void _onTabChanged( + CommunityManagementTabChanged event, + Emitter emit, + ) { + emit(state.copyWith(activeTab: event.tab)); + } + + Future _onLoadEngagementsRequested( + LoadEngagementsRequested event, + Emitter emit, + ) async { + _logger.info('Loading engagements with filter: ${event.filter}'); + emit( + state.copyWith( + engagementsStatus: CommunityManagementStatus.loading, + engagements: event.forceRefresh ? [] : state.engagements, + ), + ); + try { + final response = await _engagementsRepository.readAll( + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), + filter: event.filter, + sort: [const SortOption('createdAt', SortOrder.desc)], + ); + final newEngagements = event.forceRefresh + ? response.items + : [...state.engagements, ...response.items]; + + emit( + state.copyWith( + engagementsStatus: CommunityManagementStatus.success, + engagements: newEngagements, + engagementsCursor: response.cursor, + hasMoreEngagements: response.hasMore, + forceEngagementsCursor: true, + ), + ); + } on HttpException catch (e) { + _logger.severe('Failed to load engagements', e); + emit( + state.copyWith( + engagementsStatus: CommunityManagementStatus.failure, + exception: e, + ), + ); + } + } + + Future _onLoadReportsRequested( + LoadReportsRequested event, + Emitter emit, + ) async { + _logger.info('Loading reports with filter: ${event.filter}'); + emit( + state.copyWith( + reportsStatus: CommunityManagementStatus.loading, + reports: event.forceRefresh ? [] : state.reports, + ), + ); + try { + final response = await _reportsRepository.readAll( + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), + filter: event.filter, + sort: [const SortOption('createdAt', SortOrder.desc)], + ); + final newReports = event.forceRefresh + ? response.items + : [...state.reports, ...response.items]; + + emit( + state.copyWith( + reportsStatus: CommunityManagementStatus.success, + reports: newReports, + reportsCursor: response.cursor, + hasMoreReports: response.hasMore, + forceReportsCursor: true, + ), + ); + } on HttpException catch (e) { + _logger.severe('Failed to load reports', e); + emit( + state.copyWith( + reportsStatus: CommunityManagementStatus.failure, + exception: e, + ), + ); + } + } + + Future _onLoadAppReviewsRequested( + LoadAppReviewsRequested event, + Emitter emit, + ) async { + _logger.info('Loading app reviews with filter: ${event.filter}'); + emit( + state.copyWith( + appReviewsStatus: CommunityManagementStatus.loading, + appReviews: event.forceRefresh ? [] : state.appReviews, + ), + ); + try { + final response = await _appReviewsRepository.readAll( + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), + filter: event.filter, + sort: [const SortOption('updatedAt', SortOrder.desc)], + ); + final newAppReviews = event.forceRefresh + ? response.items + : [...state.appReviews, ...response.items]; + + emit( + state.copyWith( + appReviewsStatus: CommunityManagementStatus.success, + appReviews: newAppReviews, + appReviewsCursor: response.cursor, + hasMoreAppReviews: response.hasMore, + forceAppReviewsCursor: true, + ), + ); + } on HttpException catch (e) { + _logger.severe('Failed to load app reviews', e); + emit( + state.copyWith( + appReviewsStatus: CommunityManagementStatus.failure, + exception: e, + ), + ); + } + } + + Future _onApproveCommentRequested( + ApproveCommentRequested event, + Emitter emit, + ) async { + try { + final originalEngagement = state.engagements.firstWhere( + (e) => e.id == event.engagementId, + ); + + if (originalEngagement.comment == null) return; + + final updatedEngagement = originalEngagement.copyWith( + comment: originalEngagement.comment!.copyWith( + status: ModerationStatus.resolved, + ), + ); + + final updatedEngagements = List.from(state.engagements) + ..[state.engagements.indexOf(originalEngagement)] = updatedEngagement; + + emit( + state.copyWith( + engagements: updatedEngagements, + lastPendingUpdateId: event.engagementId, + snackbarMessage: 'Comment approved.', + ), + ); + + _pendingUpdatesService.requestUpdate( + originalItem: originalEngagement, + updatedItem: updatedEngagement, + repository: _engagementsRepository, + undoDuration: AppConstants.kSnackbarDuration, + ); + } catch (e) { + _logger.severe('Error approving comment: $e'); + } + } + + Future _onRejectCommentRequested( + RejectCommentRequested event, + Emitter emit, + ) async { + try { + final originalEngagement = state.engagements.firstWhere( + (e) => e.id == event.engagementId, + ); + + // TODO(fulleni): Fix the following BUG + // The original Engagement.copyWith method has a bug where + // `comment: comment ?? this.comment` prevents setting the comment to null. + // To work around this without modifying the core model, we must create a + // new instance of Engagement manually, copying all properties and + // explicitly setting the comment to null. + final updatedEngagement = Engagement( + id: originalEngagement.id, + userId: originalEngagement.userId, + entityId: originalEngagement.entityId, + entityType: originalEngagement.entityType, + reaction: originalEngagement.reaction, + createdAt: originalEngagement.createdAt, + updatedAt: DateTime.now(), + comment: null, // Explicitly set to null to reject/delete the comment. + ); + + final updatedEngagements = List.from(state.engagements) + ..[state.engagements.indexOf(originalEngagement)] = updatedEngagement; + + emit( + state.copyWith( + engagements: updatedEngagements, + lastPendingUpdateId: event.engagementId, + snackbarMessage: 'Comment rejected.', + ), + ); + + _pendingUpdatesService.requestUpdate( + originalItem: originalEngagement, + updatedItem: updatedEngagement, + repository: _engagementsRepository, + undoDuration: AppConstants.kSnackbarDuration, + ); + } catch (e) { + _logger.severe('Error rejecting comment: $e'); + } + } + + Future _onResolveReportRequested( + ResolveReportRequested event, + Emitter emit, + ) async { + try { + final originalReport = state.reports.firstWhere( + (r) => r.id == event.reportId, + ); + + final updatedReport = originalReport.copyWith( + status: ModerationStatus.resolved, + ); + + final updatedReports = List.from(state.reports) + ..[state.reports.indexOf(originalReport)] = updatedReport; + + emit( + state.copyWith( + reports: updatedReports, + lastPendingUpdateId: event.reportId, + snackbarMessage: 'Report resolved.', + ), + ); + + _pendingUpdatesService.requestUpdate( + originalItem: originalReport, + updatedItem: updatedReport, + repository: _reportsRepository, + undoDuration: AppConstants.kSnackbarDuration, + ); + } catch (e) { + _logger.severe('Error resolving report: $e'); + } + } + + void _onUndoUpdateRequested( + UndoUpdateRequested event, + Emitter emit, + ) { + _pendingUpdatesService.undoUpdate(event.id); + } + + Future _onUpdateEventReceived( + UpdateEventReceived event, + Emitter emit, + ) async { + switch (event.event.status) { + case UpdateStatus.confirmed: + emit( + state.copyWith( + lastPendingUpdateId: null, + snackbarMessage: null, + ), + ); + case UpdateStatus.undone: + final item = event.event.originalItem; + if (item is Engagement) { + final index = state.engagements.indexWhere((e) => e.id == item.id); + if (index != -1) { + final updatedEngagements = List.from(state.engagements) + ..[index] = item; + emit( + state.copyWith( + engagements: updatedEngagements, + lastPendingUpdateId: null, + snackbarMessage: null, + ), + ); + } else { + emit( + state.copyWith(lastPendingUpdateId: null, snackbarMessage: null), + ); + } + } else if (item is Report) { + final index = state.reports.indexWhere((r) => r.id == item.id); + if (index != -1) { + final updatedReports = List.from(state.reports) + ..[index] = item; + emit( + state.copyWith( + reports: updatedReports, + lastPendingUpdateId: null, + snackbarMessage: null, + ), + ); + } else { + emit( + state.copyWith(lastPendingUpdateId: null, snackbarMessage: null), + ); + } + } else { + emit( + state.copyWith(lastPendingUpdateId: null, snackbarMessage: null), + ); + } + } + } +} diff --git a/lib/community_management/bloc/community_management_event.dart b/lib/community_management/bloc/community_management_event.dart new file mode 100644 index 00000000..f7750026 --- /dev/null +++ b/lib/community_management/bloc/community_management_event.dart @@ -0,0 +1,113 @@ +part of 'community_management_bloc.dart'; + +abstract class CommunityManagementEvent extends Equatable { + const CommunityManagementEvent(); + + @override + List get props => []; +} + +class CommunityManagementTabChanged extends CommunityManagementEvent { + const CommunityManagementTabChanged(this.tab); + + final CommunityManagementTab tab; + + @override + List get props => [tab]; +} + +class LoadEngagementsRequested extends CommunityManagementEvent { + const LoadEngagementsRequested({ + this.startAfterId, + this.limit, + this.filter, + this.forceRefresh = false, + }); + + final String? startAfterId; + final int? limit; + final Map? filter; + final bool forceRefresh; + + @override + List get props => [startAfterId, limit, filter, forceRefresh]; +} + +class LoadReportsRequested extends CommunityManagementEvent { + const LoadReportsRequested({ + this.startAfterId, + this.limit, + this.filter, + this.forceRefresh = false, + }); + + final String? startAfterId; + final int? limit; + final Map? filter; + final bool forceRefresh; + + @override + List get props => [startAfterId, limit, filter, forceRefresh]; +} + +class LoadAppReviewsRequested extends CommunityManagementEvent { + const LoadAppReviewsRequested({ + this.startAfterId, + this.limit, + this.filter, + this.forceRefresh = false, + }); + + final String? startAfterId; + final int? limit; + final Map? filter; + final bool forceRefresh; + + @override + List get props => [startAfterId, limit, filter, forceRefresh]; +} + +final class ApproveCommentRequested extends CommunityManagementEvent { + const ApproveCommentRequested(this.engagementId); + + final String engagementId; + + @override + List get props => [engagementId]; +} + +final class RejectCommentRequested extends CommunityManagementEvent { + const RejectCommentRequested(this.engagementId); + + final String engagementId; + + @override + List get props => [engagementId]; +} + +final class ResolveReportRequested extends CommunityManagementEvent { + const ResolveReportRequested(this.reportId); + + final String reportId; + + @override + List get props => [reportId]; +} + +final class UndoUpdateRequested extends CommunityManagementEvent { + const UndoUpdateRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +final class UpdateEventReceived extends CommunityManagementEvent { + const UpdateEventReceived(this.event); + + final UpdateEvent event; + + @override + List get props => [event]; +} diff --git a/lib/community_management/bloc/community_management_state.dart b/lib/community_management/bloc/community_management_state.dart new file mode 100644 index 00000000..0fc44187 --- /dev/null +++ b/lib/community_management/bloc/community_management_state.dart @@ -0,0 +1,110 @@ +part of 'community_management_bloc.dart'; + +enum CommunityManagementTab { engagements, reports, appReviews } + +enum CommunityManagementStatus { initial, loading, success, failure } + +class CommunityManagementState extends Equatable { + const CommunityManagementState({ + this.activeTab = CommunityManagementTab.engagements, + this.engagementsStatus = CommunityManagementStatus.initial, + this.reportsStatus = CommunityManagementStatus.initial, + this.appReviewsStatus = CommunityManagementStatus.initial, + this.engagements = const [], + this.reports = const [], + this.appReviews = const [], + this.engagementsCursor, + this.reportsCursor, + this.appReviewsCursor, + this.hasMoreEngagements = true, + this.hasMoreReports = true, + this.hasMoreAppReviews = true, + this.exception, + this.lastPendingUpdateId, + this.snackbarMessage, + }); + + final CommunityManagementTab activeTab; + final CommunityManagementStatus engagementsStatus; + final CommunityManagementStatus reportsStatus; + final CommunityManagementStatus appReviewsStatus; + final List engagements; + final List reports; + final List appReviews; + final String? engagementsCursor; + final String? reportsCursor; + final String? appReviewsCursor; + final bool hasMoreEngagements; + final bool hasMoreReports; + final bool hasMoreAppReviews; + final HttpException? exception; + final String? lastPendingUpdateId; + final String? snackbarMessage; + + CommunityManagementState copyWith({ + CommunityManagementTab? activeTab, + CommunityManagementStatus? engagementsStatus, + CommunityManagementStatus? reportsStatus, + CommunityManagementStatus? appReviewsStatus, + List? engagements, + List? reports, + List? appReviews, + String? engagementsCursor, + String? reportsCursor, + String? appReviewsCursor, + bool? hasMoreEngagements, + bool? hasMoreReports, + bool? hasMoreAppReviews, + HttpException? exception, + String? lastPendingUpdateId, + String? snackbarMessage, + bool forceEngagementsCursor = false, + bool forceReportsCursor = false, + bool forceAppReviewsCursor = false, + }) { + return CommunityManagementState( + activeTab: activeTab ?? this.activeTab, + engagementsStatus: engagementsStatus ?? this.engagementsStatus, + reportsStatus: reportsStatus ?? this.reportsStatus, + appReviewsStatus: appReviewsStatus ?? this.appReviewsStatus, + engagements: engagements ?? this.engagements, + reports: reports ?? this.reports, + appReviews: appReviews ?? this.appReviews, + engagementsCursor: forceEngagementsCursor + ? engagementsCursor + : engagementsCursor ?? this.engagementsCursor, + reportsCursor: forceReportsCursor + ? reportsCursor + : reportsCursor ?? this.reportsCursor, + appReviewsCursor: forceAppReviewsCursor + ? appReviewsCursor + : appReviewsCursor ?? this.appReviewsCursor, + hasMoreEngagements: hasMoreEngagements ?? this.hasMoreEngagements, + hasMoreReports: hasMoreReports ?? this.hasMoreReports, + hasMoreAppReviews: hasMoreAppReviews ?? this.hasMoreAppReviews, + exception: exception, + lastPendingUpdateId: lastPendingUpdateId, + snackbarMessage: snackbarMessage, + ); + } + + @override + List get props => [ + activeTab, + engagementsStatus, + reportsStatus, + appReviewsStatus, + engagements, + reports, + appReviews, + engagementsCursor, + reportsCursor, + appReviewsCursor, + hasMoreEngagements, + hasMoreReports, + hasMoreAppReviews, + exception, + lastPendingUpdateId, + snackbarMessage, + ]; +} diff --git a/lib/community_management/view/app_reviews_page.dart b/lib/community_management/view/app_reviews_page.dart new file mode 100644 index 00000000..241d099e --- /dev/null +++ b/lib/community_management/view/app_reviews_page.dart @@ -0,0 +1,248 @@ +import 'package:core/core.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/community_action_buttons.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_review_feedback_extension.dart'; +import 'package:intl/intl.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class AppReviewsPage extends StatefulWidget { + const AppReviewsPage({super.key}); + + @override + State createState() => _AppReviewsPageState(); +} + +class _AppReviewsPageState extends State { + @override + void initState() { + super.initState(); + context.read().add( + LoadAppReviewsRequested( + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildAppReviewsFilterMap( + context.read().state.appReviewsFilter, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: BlocBuilder( + builder: (context, state) { + final filtersActive = context + .watch() + .state + .appReviewsFilter + .isFilterActive; + + if (state.appReviewsStatus == CommunityManagementStatus.loading && + state.appReviews.isEmpty) { + return LoadingStateWidget( + icon: Icons.reviews_outlined, + headline: l10n.loadingAppReviews, + subheadline: l10n.pleaseWait, + ); + } + + if (state.appReviewsStatus == CommunityManagementStatus.failure) { + return FailureStateWidget( + exception: state.exception!, + onRetry: () => context.read().add( + LoadAppReviewsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildAppReviewsFilterMap( + context + .read() + .state + .appReviewsFilter, + ), + ), + ), + ); + } + + if (state.appReviews.isEmpty) { + if (filtersActive) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noResultsWithCurrentFilters, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: () => context.read().add( + const CommunityFilterReset(), + ), + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } + return Center(child: Text(l10n.noAppReviewsFound)); + } + + return Column( + children: [ + if (state.appReviewsStatus == CommunityManagementStatus.loading && + state.appReviews.isNotEmpty) + const LinearProgressIndicator(), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.feedback), + size: ColumnSize.M, + ), + DataColumn2( + label: Text(l10n.lastInteraction), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _AppReviewsDataSource( + context: context, + appReviews: state.appReviews, + hasMore: state.hasMoreAppReviews, + l10n: l10n, + isMobile: isMobile, + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.appReviews.length && + state.hasMoreAppReviews && + state.appReviewsStatus != + CommunityManagementStatus.loading) { + context.read().add( + LoadAppReviewsRequested( + startAfterId: state.appReviewsCursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildAppReviewsFilterMap( + context + .read() + .state + .appReviewsFilter, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noAppReviewsFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.sm, + horizontalMargin: AppSpacing.sm, + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _AppReviewsDataSource extends DataTableSource { + _AppReviewsDataSource({ + required this.context, + required this.appReviews, + required this.hasMore, + required this.l10n, + required this.isMobile, + }); + + final BuildContext context; + final List appReviews; + final bool hasMore; + final AppLocalizations l10n; + final bool isMobile; + + @override + DataRow? getRow(int index) { + if (index >= appReviews.length) return null; + final appReview = appReviews[index]; + return DataRow2( + cells: [ + DataCell( + Chip( + avatar: Icon( + _getFeedbackIcon(appReview.feedback), + size: 16, + ), + label: Text(appReview.feedback.l10n(context)), + backgroundColor: _getFeedbackColor(context, appReview.feedback), + side: BorderSide.none, + ), + ), + DataCell( + Text( + DateFormat('dd-MM-yyyy').format(appReview.updatedAt.toLocal()), + ), + ), + DataCell(CommunityActionButtons(item: appReview, l10n: l10n)), + ], + ); + } + + @override + bool get isRowCountApproximate => hasMore; + + @override + int get rowCount => appReviews.length; + + @override + int get selectedRowCount => 0; + + Color? _getFeedbackColor(BuildContext context, AppReviewFeedback feedback) { + final colorScheme = Theme.of(context).colorScheme; + switch (feedback) { + case AppReviewFeedback.positive: + return colorScheme.primaryContainer; + case AppReviewFeedback.negative: + return colorScheme.errorContainer; + } + } + + IconData _getFeedbackIcon(AppReviewFeedback feedback) { + switch (feedback) { + case AppReviewFeedback.positive: + return Icons.thumb_up; + case AppReviewFeedback.negative: + return Icons.thumb_down; + } + } +} diff --git a/lib/community_management/view/community_management_page.dart b/lib/community_management/view/community_management_page.dart new file mode 100644 index 00000000..d058ec92 --- /dev/null +++ b/lib/community_management/view/community_management_page.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/view/app_reviews_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/view/engagements_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/view/reports_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/about_icon.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class CommunityManagementPage extends StatefulWidget { + const CommunityManagementPage({super.key}); + + @override + State createState() => + _CommunityManagementPageState(); +} + +class _CommunityManagementPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + _tabController + ..removeListener(_onTabChanged) + ..dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + final tab = CommunityManagementTab.values[_tabController.index]; + context.read().add( + CommunityManagementTabChanged(tab), + ); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + return MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => + previous.version != current.version, + listener: (context, filterState) { + final communityManagementBloc = context + .read(); + switch (communityManagementBloc.state.activeTab) { + case CommunityManagementTab.engagements: + communityManagementBloc.add( + LoadEngagementsRequested( + filter: communityManagementBloc.buildEngagementsFilterMap( + filterState.engagementsFilter, + ), + forceRefresh: true, + ), + ); + case CommunityManagementTab.reports: + communityManagementBloc.add( + LoadReportsRequested( + filter: communityManagementBloc.buildReportsFilterMap( + filterState.reportsFilter, + ), + forceRefresh: true, + ), + ); + case CommunityManagementTab.appReviews: + communityManagementBloc.add( + LoadAppReviewsRequested( + filter: communityManagementBloc.buildAppReviewsFilterMap( + filterState.appReviewsFilter, + ), + forceRefresh: true, + ), + ); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.snackbarMessage != current.snackbarMessage && + current.snackbarMessage != null, + listener: (context, state) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.snackbarMessage!), + action: SnackBarAction( + label: l10n.undo, + onPressed: () { + if (state.lastPendingUpdateId != null) { + context.read().add( + UndoUpdateRequested(state.lastPendingUpdateId!), + ); + } + }, + ), + ), + ); + }, + ), + ], + child: Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.communityManagement), + const SizedBox(width: AppSpacing.xs), + AboutIcon( + dialogTitle: l10n.communityManagement, + dialogDescription: l10n.communityManagementPageDescription, + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.filter_list), + tooltip: l10n.filterCommunity, + onPressed: () { + context.pushNamed(Routes.communityFilterDialogName); + }, + ), + ], + bottom: TabBar( + controller: _tabController, + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: [ + Tab(text: l10n.engagements), + Tab(text: l10n.reports), + Tab(text: l10n.appReviews), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + EngagementsPage(), + ReportsPage(), + AppReviewsPage(), + ], + ), + ), + ); + } +} diff --git a/lib/community_management/view/engagements_page.dart b/lib/community_management/view/engagements_page.dart new file mode 100644 index 00000000..5abf789f --- /dev/null +++ b/lib/community_management/view/engagements_page.dart @@ -0,0 +1,245 @@ +import 'package:core/core.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/community_action_buttons.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:intl/intl.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class EngagementsPage extends StatefulWidget { + const EngagementsPage({super.key}); + + @override + State createState() => _EngagementsPageState(); +} + +class _EngagementsPageState extends State { + @override + void initState() { + super.initState(); + context.read().add( + LoadEngagementsRequested( + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildEngagementsFilterMap( + context.read().state.engagementsFilter, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: BlocBuilder( + builder: (context, state) { + final filtersActive = context + .watch() + .state + .engagementsFilter + .isFilterActive; + + if (state.engagementsStatus == CommunityManagementStatus.loading && + state.engagements.isEmpty) { + return LoadingStateWidget( + icon: Icons.comment_outlined, + headline: l10n.loadingEngagements, + subheadline: l10n.pleaseWait, + ); + } + + if (state.engagementsStatus == CommunityManagementStatus.failure) { + return FailureStateWidget( + exception: state.exception!, + onRetry: () => context.read().add( + LoadEngagementsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildEngagementsFilterMap( + context + .read() + .state + .engagementsFilter, + ), + ), + ), + ); + } + + if (state.engagements.isEmpty) { + if (filtersActive) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noResultsWithCurrentFilters, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: () => context.read().add( + const CommunityFilterReset(), + ), + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } + return Center(child: Text(l10n.noEngagementsFound)); + } + + return Column( + children: [ + if (state.engagementsStatus == + CommunityManagementStatus.loading && + state.engagements.isNotEmpty) + const LinearProgressIndicator(), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.reaction), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.date), + size: ColumnSize.M, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _EngagementsDataSource( + context: context, + engagements: state.engagements, + hasMore: state.hasMoreEngagements, + l10n: l10n, + isMobile: isMobile, + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.engagements.length && + state.hasMoreEngagements && + state.engagementsStatus != + CommunityManagementStatus.loading) { + context.read().add( + LoadEngagementsRequested( + startAfterId: state.engagementsCursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildEngagementsFilterMap( + context + .read() + .state + .engagementsFilter, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noEngagementsFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.sm, + horizontalMargin: AppSpacing.sm, + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _EngagementsDataSource extends DataTableSource { + _EngagementsDataSource({ + required this.context, + required this.engagements, + required this.hasMore, + required this.l10n, + required this.isMobile, + }); + + final BuildContext context; + final List engagements; + final bool hasMore; + final AppLocalizations l10n; + final bool isMobile; + + @override + DataRow? getRow(int index) { + if (index >= engagements.length) return null; + final engagement = engagements[index]; + return DataRow2( + cells: [ + DataCell( + Chip( + label: Text(engagement.reaction.reactionType.name), + backgroundColor: _getReactionColor( + context, + engagement.reaction.reactionType, + ), + side: BorderSide.none, + visualDensity: VisualDensity.compact, + ), + ), + DataCell( + Text(DateFormat('dd-MM-yyyy').format(engagement.createdAt.toLocal())), + ), + DataCell(CommunityActionButtons(item: engagement, l10n: l10n)), + ], + ); + } + + @override + bool get isRowCountApproximate => hasMore; + + @override + int get rowCount => engagements.length; + + @override + int get selectedRowCount => 0; + + Color? _getReactionColor(BuildContext context, ReactionType reactionType) { + final colorScheme = Theme.of(context).colorScheme; + switch (reactionType) { + case ReactionType.like: + return colorScheme.primaryContainer.withOpacity(0.5); + case ReactionType.insightful: + return colorScheme.tertiaryContainer.withOpacity(0.5); + case ReactionType.amusing: + return colorScheme.secondaryContainer.withOpacity(0.5); + case ReactionType.sad: + return Colors.blueGrey.withOpacity(0.2); + case ReactionType.angry: + return colorScheme.errorContainer.withOpacity(0.4); + case ReactionType.skeptical: + return colorScheme.onSurface.withOpacity(0.1); + } + } +} diff --git a/lib/community_management/view/reports_page.dart b/lib/community_management/view/reports_page.dart new file mode 100644 index 00000000..93af6eeb --- /dev/null +++ b/lib/community_management/view/reports_page.dart @@ -0,0 +1,234 @@ +import 'package:core/core.dart'; +import 'package:data_table_2/data_table_2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/community_action_buttons.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; +import 'package:intl/intl.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class ReportsPage extends StatefulWidget { + const ReportsPage({super.key}); + + @override + State createState() => _ReportsPageState(); +} + +class _ReportsPageState extends State { + @override + void initState() { + super.initState(); + context.read().add( + LoadReportsRequested( + limit: kDefaultRowsPerPage, + filter: context.read().buildReportsFilterMap( + context.read().state.reportsFilter, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: BlocBuilder( + builder: (context, state) { + final filtersActive = context + .watch() + .state + .reportsFilter + .isFilterActive; + + if (state.reportsStatus == CommunityManagementStatus.loading && + state.reports.isEmpty) { + return LoadingStateWidget( + icon: Icons.report_problem_outlined, + headline: l10n.loadingReports, + subheadline: l10n.pleaseWait, + ); + } + + if (state.reportsStatus == CommunityManagementStatus.failure) { + return FailureStateWidget( + exception: state.exception!, + onRetry: () => context.read().add( + LoadReportsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildReportsFilterMap( + context.read().state.reportsFilter, + ), + ), + ), + ); + } + + if (state.reports.isEmpty) { + if (filtersActive) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noResultsWithCurrentFilters, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: () => context.read().add( + const CommunityFilterReset(), + ), + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } + return Center(child: Text(l10n.noReportsFound)); + } + + return Column( + children: [ + if (state.reportsStatus == CommunityManagementStatus.loading && + state.reports.isNotEmpty) + const LinearProgressIndicator(), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.entityType), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.date), + size: ColumnSize.M, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.L, + ), + ], + source: _ReportsDataSource( + context: context, + reports: state.reports, + hasMore: state.hasMoreReports, + l10n: l10n, + isMobile: isMobile, + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.reports.length && + state.hasMoreReports && + state.reportsStatus != + CommunityManagementStatus.loading) { + context.read().add( + LoadReportsRequested( + startAfterId: state.reportsCursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildReportsFilterMap( + context + .read() + .state + .reportsFilter, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noReportsFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.sm, + horizontalMargin: AppSpacing.sm, + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _ReportsDataSource extends DataTableSource { + _ReportsDataSource({ + required this.context, + required this.reports, + required this.hasMore, + required this.l10n, + required this.isMobile, + }); + + final BuildContext context; + final List reports; + final bool hasMore; + final AppLocalizations l10n; + final bool isMobile; + + @override + DataRow? getRow(int index) { + if (index >= reports.length) return null; + final report = reports[index]; + return DataRow2( + cells: [ + DataCell( + Chip( + label: Text(report.entityType.l10n(context)), + backgroundColor: _getEntityTypeColor(context, report.entityType), + side: BorderSide.none, + visualDensity: VisualDensity.compact, + ), + ), + DataCell( + Text(DateFormat('dd-MM-yyyy').format(report.createdAt.toLocal())), + ), + DataCell(CommunityActionButtons(item: report, l10n: l10n)), + ], + ); + } + + @override + bool get isRowCountApproximate => hasMore; + + @override + int get rowCount => reports.length; + + @override + int get selectedRowCount => 0; + + Color? _getEntityTypeColor( + BuildContext context, + ReportableEntity entityType, + ) { + final colorScheme = Theme.of(context).colorScheme; + switch (entityType) { + case ReportableEntity.headline: + return colorScheme.primaryContainer.withOpacity(0.5); + case ReportableEntity.source: + return colorScheme.secondaryContainer.withOpacity(0.5); + case ReportableEntity.engagement: + return colorScheme.tertiaryContainer.withOpacity(0.5); + } + } +} diff --git a/lib/community_management/widgets/app_review_details_dialog.dart b/lib/community_management/widgets/app_review_details_dialog.dart new file mode 100644 index 00000000..87288b4d --- /dev/null +++ b/lib/community_management/widgets/app_review_details_dialog.dart @@ -0,0 +1,43 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; + +class AppReviewDetailsDialog extends StatelessWidget { + const AppReviewDetailsDialog({ + required this.appReview, + super.key, + }); + + final AppReview appReview; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final details = appReview.feedbackDetails; + + return AlertDialog( + title: Text(l10n.feedbackDetails), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 300), + child: SingleChildScrollView( + child: Text( + details ?? l10n.noReasonProvided, + style: (details == null) + ? theme.textTheme.bodyLarge?.copyWith( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ) + : theme.textTheme.bodyLarge, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeButtonText), + ), + ], + ); + } +} diff --git a/lib/community_management/widgets/community_action_buttons.dart b/lib/community_management/widgets/community_action_buttons.dart new file mode 100644 index 00000000..64c1b812 --- /dev/null +++ b/lib/community_management/widgets/community_action_buttons.dart @@ -0,0 +1,238 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/app_review_details_dialog.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/engagement_details_dialog.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/report_details_dialog.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; + +class CommunityActionButtons extends StatelessWidget { + const CommunityActionButtons({ + required this.item, + required this.l10n, + super.key, + }); + + final T item; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final visibleActions = []; + final overflowMenuItems = >[]; + + if (item is Engagement) { + final engagement = item as Engagement; + _buildEngagementActions( + context, + engagement, + visibleActions, + overflowMenuItems, + ); + } else if (item is Report) { + final report = item as Report; + _buildReportActions( + context, + report, + visibleActions, + overflowMenuItems, + ); + } else if (item is AppReview) { + final appReview = item as AppReview; + _buildAppReviewActions( + context, + appReview, + visibleActions, + overflowMenuItems, + ); + } + + if (overflowMenuItems.isNotEmpty) { + visibleActions.add( + SizedBox( + width: 32, + child: PopupMenuButton( + iconSize: 20, + icon: const Icon(Icons.more_vert), + tooltip: l10n.moreActions, + onSelected: (value) => _onActionSelected(context, value, item), + itemBuilder: (context) => overflowMenuItems, + ), + ), + ); + } + + return Row(mainAxisSize: MainAxisSize.min, children: visibleActions); + } + + void _buildEngagementActions( + BuildContext context, + Engagement engagement, + List visibleActions, + List> overflowMenuItems, + ) { + final isPendingReview = + engagement.comment != null && + engagement.comment!.status == ModerationStatus.pendingReview; + + visibleActions.add( + Stack( + children: [ + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 20, + icon: const Icon(Icons.comment_outlined), + tooltip: l10n.viewFeedbackDetails, + onPressed: () => showDialog( + context: context, + builder: (_) => EngagementDetailsDialog(engagement: engagement), + ), + ), + if (isPendingReview) + Positioned( + top: 8, + right: 8, + child: Tooltip( + message: l10n.moderationStatusPendingReview, + child: const Icon( + Icons.circle, + color: Colors.amber, + size: 10, + ), + ), + ), + ], + ), + ); + + // Secondary Actions + overflowMenuItems + ..add( + PopupMenuItem( + value: 'copyUserId', + child: Text(l10n.copyUserId), + ), + ) + ..add( + PopupMenuItem( + value: 'copyHeadlineId', + child: Text(l10n.copyHeadlineId), + ), + ); + } + + void _buildReportActions( + BuildContext context, + Report report, + List visibleActions, + List> overflowMenuItems, + ) { + final isPendingReview = report.status == ModerationStatus.pendingReview; + + visibleActions.add( + Stack( + children: [ + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 20, + icon: const Icon(Icons.comment_outlined), + tooltip: l10n.viewFeedbackDetails, + onPressed: () => showDialog( + context: context, + builder: (_) => ReportDetailsDialog(report: report), + ), + ), + if (isPendingReview) + Positioned( + top: 8, + right: 8, + child: Tooltip( + message: l10n.moderationStatusPendingReview, + child: const Icon( + Icons.circle, + color: Colors.amber, + size: 10, + ), + ), + ), + ], + ), + ); + + // Secondary Actions + overflowMenuItems + ..add( + PopupMenuItem( + value: 'copyUserId', + child: Text(l10n.copyUserId), + ), + ) + ..add( + PopupMenuItem( + value: 'copyReportedItemId', + child: Text(l10n.copyReportedItemId), + ), + ); + } + + void _buildAppReviewActions( + BuildContext context, + AppReview appReview, + List visibleActions, + List> overflowMenuItems, + ) { + // Primary Action + visibleActions.add( + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 20, + icon: const Icon(Icons.comment_outlined), + tooltip: l10n.viewFeedbackDetails, + onPressed: () => showDialog( + context: context, + builder: (_) => AppReviewDetailsDialog(appReview: appReview), + ), + ), + ); + + // Secondary Actions + overflowMenuItems.add( + PopupMenuItem(value: 'copyUserId', child: Text(l10n.copyUserId)), + ); + } + + void _onActionSelected(BuildContext context, String value, T item) { + if (value == 'copyUserId') { + String userId; + if (item is Engagement) { + userId = item.userId; + } else if (item is Report) { + userId = item.reporterUserId; + } else if (item is AppReview) { + userId = item.userId; + } else { + return; + } + Clipboard.setData(ClipboardData(text: userId)); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.userIdCopied))); + } else if (value == 'copyReportedItemId' && item is Report) { + final reportedItemId = item.entityId; + Clipboard.setData(ClipboardData(text: reportedItemId)); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.idCopiedToClipboard(reportedItemId))), + ); + } else if (value == 'copyHeadlineId' && item is Engagement) { + final headlineId = item.entityId; + Clipboard.setData(ClipboardData(text: headlineId)); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.idCopiedToClipboard(headlineId))), + ); + } + } +} diff --git a/lib/community_management/widgets/community_filter_dialog/community_filter_dialog.dart b/lib/community_management/widgets/community_filter_dialog/community_filter_dialog.dart new file mode 100644 index 00000000..cfa99379 --- /dev/null +++ b/lib/community_management/widgets/community_filter_dialog/community_filter_dialog.dart @@ -0,0 +1,327 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_filter/community_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart' + show CommunityManagementBloc, CommunityManagementTab; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class CommunityFilterDialog extends StatefulWidget { + const CommunityFilterDialog({super.key}); + + @override + State createState() => _CommunityFilterDialogState(); +} + +class _CommunityFilterDialogState extends State { + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + final filterState = context.read().state; + final activeTab = context.read().state.activeTab; + switch (activeTab) { + case CommunityManagementTab.engagements: + _searchController.text = + filterState.engagementsFilter.searchQuery ?? ''; + case CommunityManagementTab.reports: + _searchController.text = filterState.reportsFilter.searchQuery ?? ''; + case CommunityManagementTab.appReviews: + _searchController.text = filterState.appReviewsFilter.searchQuery ?? ''; + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + final activeTab = context + .select( + (bloc) => bloc.state.activeTab, + ); + + return BlocBuilder( + builder: (context, filterState) { + _updateSearchController(filterState, activeTab); + _searchController.selection = TextSelection.fromPosition( + TextPosition(offset: _searchController.text.length), + ); + + return Scaffold( + appBar: AppBar( + title: Text(l10n.filterCommunity), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: l10n.resetFiltersButtonText, + onPressed: () { + final filterBloc = context.read() + ..add(const CommunityFilterReset()); + _dispatchFilterChanges( + filterBloc, + const CommunityFilterState(), + ); + filterBloc.add(const CommunityFilterApplied()); + Navigator.of(context).pop(); + }, + ), + IconButton( + icon: const Icon(Icons.check), + tooltip: l10n.applyFilters, + onPressed: () { + context.read().add( + const CommunityFilterApplied(), + ); + Navigator.of(context).pop(); + }, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: l10n.searchByUserId, + hintText: l10n.searchByUserId, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + ), + onChanged: (query) { + _onSearchQueryChanged(context, query, activeTab); + }, + ), + const SizedBox(height: AppSpacing.lg), + ..._buildTabSpecificFilters( + activeTab, + filterState, + l10n, + theme, + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _updateSearchController( + CommunityFilterState filterState, + CommunityManagementTab activeTab, + ) { + final String currentSearchQuery; + switch (activeTab) { + case CommunityManagementTab.engagements: + currentSearchQuery = filterState.engagementsFilter.searchQuery ?? ''; + case CommunityManagementTab.reports: + currentSearchQuery = filterState.reportsFilter.searchQuery ?? ''; + case CommunityManagementTab.appReviews: + currentSearchQuery = filterState.appReviewsFilter.searchQuery ?? ''; + } + + if (_searchController.text != currentSearchQuery) { + _searchController.text = currentSearchQuery; + } + } + + void _dispatchFilterChanges( + CommunityFilterBloc bloc, + CommunityFilterState state, + ) { + bloc + ..add(EngagementsFilterChanged(state.engagementsFilter)) + ..add(ReportsFilterChanged(state.reportsFilter)) + ..add(AppReviewsFilterChanged(state.appReviewsFilter)); + } + + void _onSearchQueryChanged( + BuildContext context, + String query, + CommunityManagementTab activeTab, + ) { + final bloc = context.read(); + final state = bloc.state; + switch (activeTab) { + case CommunityManagementTab.engagements: + bloc.add( + EngagementsFilterChanged( + EngagementsFilter( + searchQuery: query, + selectedStatus: state.engagementsFilter.selectedStatus, + ), + ), + ); + case CommunityManagementTab.reports: + bloc.add( + ReportsFilterChanged( + ReportsFilter( + searchQuery: query, + selectedStatus: state.reportsFilter.selectedStatus, + selectedReportableEntity: + state.reportsFilter.selectedReportableEntity, + ), + ), + ); + case CommunityManagementTab.appReviews: + bloc.add( + AppReviewsFilterChanged( + AppReviewsFilter( + searchQuery: query, + selectedFeedback: state.appReviewsFilter.selectedFeedback, + ), + ), + ); + } + } + + Widget _buildCapsuleFilter({ + required String title, + required List allValues, + required T? selectedValue, + required String Function(T) labelBuilder, + required void Function(T?) onChanged, + }) { + final theme = Theme.of(context); + final l10n = AppLocalizationsX(context).l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + children: [ + ChoiceChip( + label: Text(l10n.any), + selected: selectedValue == null, + onSelected: (selected) { + if (selected) { + onChanged(null); + } + }, + ), + ...allValues.map((value) { + final isSelected = selectedValue == value; + return ChoiceChip( + label: Text(labelBuilder(value)), + selected: isSelected, + onSelected: (selected) => onChanged(selected ? value : null), + ); + }), + ], + ), + ], + ); + } + + List _buildTabSpecificFilters( + CommunityManagementTab activeTab, + CommunityFilterState state, + AppLocalizations l10n, + ThemeData theme, + ) { + switch (activeTab) { + case CommunityManagementTab.engagements: + return [ + _buildCapsuleFilter( + title: l10n.status, + allValues: ModerationStatus.values, + selectedValue: state.engagementsFilter.selectedStatus, + labelBuilder: (item) => item.l10n(context), + onChanged: (item) { + context.read().add( + EngagementsFilterChanged( + EngagementsFilter( + searchQuery: state.engagementsFilter.searchQuery, + selectedStatus: item, + ), + ), + ); + }, + ), + ]; + case CommunityManagementTab.reports: + return [ + _buildCapsuleFilter( + title: l10n.status, + allValues: ModerationStatus.values, + selectedValue: state.reportsFilter.selectedStatus, + labelBuilder: (item) => item.l10n(context), + onChanged: (item) { + context.read().add( + ReportsFilterChanged( + ReportsFilter( + searchQuery: state.reportsFilter.searchQuery, + selectedStatus: item, + selectedReportableEntity: + state.reportsFilter.selectedReportableEntity, + ), + ), + ); + }, + ), + const SizedBox(height: AppSpacing.lg), + _buildCapsuleFilter( + title: l10n.reportedItem, + allValues: ReportableEntity.values, + selectedValue: state.reportsFilter.selectedReportableEntity, + labelBuilder: (item) => item.l10n(context), + onChanged: (item) { + context.read().add( + ReportsFilterChanged( + ReportsFilter( + searchQuery: state.reportsFilter.searchQuery, + selectedStatus: state.reportsFilter.selectedStatus, + selectedReportableEntity: item, + ), + ), + ); + }, + ), + ]; + case CommunityManagementTab.appReviews: + return [ + _buildCapsuleFilter( + title: l10n.initialFeedback, + allValues: AppReviewFeedback.values, + selectedValue: state.appReviewsFilter.selectedFeedback, + labelBuilder: (item) => item.l10n(context), + onChanged: (item) { + context.read().add( + AppReviewsFilterChanged( + AppReviewsFilter( + searchQuery: state.appReviewsFilter.searchQuery, + selectedFeedback: item, + ), + ), + ); + }, + ), + ]; + } + } +} diff --git a/lib/community_management/widgets/engagement_details_dialog.dart b/lib/community_management/widgets/engagement_details_dialog.dart new file mode 100644 index 00000000..82282abc --- /dev/null +++ b/lib/community_management/widgets/engagement_details_dialog.dart @@ -0,0 +1,73 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +class EngagementDetailsDialog extends StatelessWidget { + const EngagementDetailsDialog({ + required this.engagement, + super.key, + }); + + final Engagement engagement; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + + final hasComment = engagement.comment != null; + final isPendingReview = + hasComment && + engagement.comment!.status == ModerationStatus.pendingReview; + + return AlertDialog( + title: Text(l10n.commentDetails), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 300), + child: SingleChildScrollView( + child: Text( + engagement.comment?.content ?? l10n.noReasonProvided, + style: hasComment + ? theme.textTheme.bodyLarge + : theme.textTheme.bodyLarge?.copyWith( + fontStyle: FontStyle.italic, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeButtonText), + ), + if (isPendingReview) ...[ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + onPressed: () { + context.read().add( + RejectCommentRequested(engagement.id), + ); + Navigator.of(context).pop(); + }, + child: Text(l10n.rejectComment), + ), + ElevatedButton( + onPressed: () { + context.read().add( + ApproveCommentRequested(engagement.id), + ); + Navigator.of(context).pop(); + }, + child: Text(l10n.approveComment), + ), + ], + ], + ); + } +} diff --git a/lib/community_management/widgets/report_details_dialog.dart b/lib/community_management/widgets/report_details_dialog.dart new file mode 100644 index 00000000..83f533f8 --- /dev/null +++ b/lib/community_management/widgets/report_details_dialog.dart @@ -0,0 +1,74 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/bloc/community_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class ReportDetailsDialog extends StatelessWidget { + const ReportDetailsDialog({ + required this.report, + super.key, + }); + + final Report report; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + + final isPendingReview = report.status == ModerationStatus.pendingReview; + + return AlertDialog( + title: Text(l10n.reportDetails), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 300), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${l10n.reason}: ${report.reason.l10n(context)}', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (report.additionalComments != null && + report.additionalComments!.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + Text( + l10n.comment, + style: theme.textTheme.labelMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + report.additionalComments!, + style: theme.textTheme.bodyLarge, + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeButtonText), + ), + if (isPendingReview) + ElevatedButton( + onPressed: () { + context.read().add( + ResolveReportRequested(report.id), + ); + Navigator.of(context).pop(); + }, + child: Text(l10n.resolveReport), + ), + ], + ); + } +} diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart index 528673c2..46e70ffe 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart @@ -116,4 +116,34 @@ class HeadlinesFilterBloc ) { emit(const HeadlinesFilterState()); } + + /// Builds the filter map for the data repository query. + Map buildFilterMap() { + final filter = {'status': state.selectedStatus.name}; + + if (state.searchQuery.isNotEmpty) { + filter[r'$or'] = [ + { + 'title': {r'$regex': state.searchQuery, r'$options': 'i'}, + }, + {'_id': state.searchQuery}, + ]; + } + + if (state.selectedSourceIds.isNotEmpty) { + filter['source.id'] = {r'$in': state.selectedSourceIds}; + } + if (state.selectedTopicIds.isNotEmpty) { + filter['topic.id'] = {r'$in': state.selectedTopicIds}; + } + if (state.selectedCountryIds.isNotEmpty) { + filter['eventCountry.id'] = {r'$in': state.selectedCountryIds}; + } + if (state.isBreaking != BreakingNewsFilterStatus.all) { + filter['isBreaking'] = + state.isBreaking == BreakingNewsFilterStatus.breakingOnly; + } + + return filter; + } } diff --git a/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart b/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart index c87ec826..1d6af971 100644 --- a/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart +++ b/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart @@ -99,4 +99,33 @@ class SourcesFilterBloc extends Bloc { ) { emit(const SourcesFilterState()); } + + /// Builds the filter map for the data repository query. + Map buildFilterMap() { + final filter = {'status': state.selectedStatus.name}; + + if (state.searchQuery.isNotEmpty) { + filter[r'$or'] = [ + { + 'name': {r'$regex': state.searchQuery, r'$options': 'i'}, + }, + {'_id': state.searchQuery}, + ]; + } + + if (state.selectedSourceTypes.isNotEmpty) { + filter['sourceType'] = { + r'$in': state.selectedSourceTypes.map((t) => t.name).toList(), + }; + } + if (state.selectedLanguageCodes.isNotEmpty) { + filter['language.code'] = {r'$in': state.selectedLanguageCodes}; + } + if (state.selectedHeadquartersCountryIds.isNotEmpty) { + filter['headquarters.id'] = { + r'$in': state.selectedHeadquartersCountryIds, + }; + } + return filter; + } } diff --git a/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart b/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart index 4e40a178..2f9be21f 100644 --- a/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart +++ b/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart @@ -63,4 +63,20 @@ class TopicsFilterBloc extends Bloc { ) { emit(const TopicsFilterState()); } + + /// Builds the filter map for the data repository query. + Map buildFilterMap() { + final filter = {'status': state.selectedStatus.name}; + + if (state.searchQuery.isNotEmpty) { + filter[r'$or'] = [ + { + 'name': {r'$regex': state.searchQuery, r'$options': 'i'}, + }, + {'_id': state.searchQuery}, + ]; + } + + return filter; + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4f9e618e..2d729a3f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2459,13 +2459,13 @@ abstract class AppLocalizations { /// Hint text for the topic search field. /// /// In en, this message translates to: - /// **'Search by topic name...'** + /// **'Search by Name or ID...'** String get searchByTopicName; /// Hint text for the source search field. /// /// In en, this message translates to: - /// **'Search by source name...'** + /// **'Search by Name or ID...'** String get searchBySourceName; /// Hint text for selecting sources in a filter dialog. @@ -2621,7 +2621,7 @@ abstract class AppLocalizations { /// Hint text for the user search field. /// /// In en, this message translates to: - /// **'Search by user email...'** + /// **'Search by Email or ID...'** String get searchByUserEmail; /// Hint text for selecting app roles in a filter dialog. @@ -3343,6 +3343,630 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Globally activates or deactivates all community-related functionality, including engagement and reporting.'** String get enableCommunityFeaturesDescription; + + /// Description for the Community Management page + /// + /// In en, this message translates to: + /// **'Manage user-generated content including engagements (reactions and comments), content reports, and app reviews.'** + String get communityManagementPageDescription; + + /// Label for the engagements subpage + /// + /// In en, this message translates to: + /// **'Engagements'** + String get engagements; + + /// Label for the reports subpage + /// + /// In en, this message translates to: + /// **'Reports'** + String get reports; + + /// Label for the app reviews subpage + /// + /// In en, this message translates to: + /// **'App Reviews'** + String get appReviews; + + /// Column header for user + /// + /// In en, this message translates to: + /// **'User'** + String get user; + + /// Column header for engaged content + /// + /// In en, this message translates to: + /// **'Engaged Content'** + String get engagedContent; + + /// Column header for reaction + /// + /// In en, this message translates to: + /// **'Reaction'** + String get reaction; + + /// Column header for comment + /// + /// In en, this message translates to: + /// **'Comment'** + String get comment; + + /// Column header for comment status + /// + /// In en, this message translates to: + /// **'Comment Status'** + String get commentStatus; + + /// Column header for date + /// + /// In en, this message translates to: + /// **'Date'** + String get date; + + /// Action to approve a comment + /// + /// In en, this message translates to: + /// **'Approve Comment'** + String get approveComment; + + /// Action to reject a comment + /// + /// In en, this message translates to: + /// **'Reject Comment'** + String get rejectComment; + + /// Action to view the engaged content + /// + /// In en, this message translates to: + /// **'View Content'** + String get viewEngagedContent; + + /// Action to copy the user ID + /// + /// In en, this message translates to: + /// **'Copy User ID'** + String get copyUserId; + + /// Column header for reporter + /// + /// In en, this message translates to: + /// **'Reporter'** + String get reporter; + + /// Column header for reported item + /// + /// In en, this message translates to: + /// **'Reported Item'** + String get reportedItem; + + /// Column header for reason + /// + /// In en, this message translates to: + /// **'Reason'** + String get reason; + + /// Column header for report status + /// + /// In en, this message translates to: + /// **'Report Status'** + String get reportStatus; + + /// Action to view the reported item + /// + /// In en, this message translates to: + /// **'View Item'** + String get viewReportedItem; + + /// Action to mark a report as in review + /// + /// In en, this message translates to: + /// **'Mark as In Review'** + String get markAsInReview; + + /// Action to resolve a report + /// + /// In en, this message translates to: + /// **'Resolve Report'** + String get resolveReport; + + /// Column header for initial feedback + /// + /// In en, this message translates to: + /// **'Initial Feedback'** + String get initialFeedback; + + /// Column header for OS prompt requested + /// + /// In en, this message translates to: + /// **'OS Prompt?'** + String get osPromptRequested; + + /// Column header for feedback history + /// + /// In en, this message translates to: + /// **'Feedback History'** + String get feedbackHistory; + + /// Column header for last interaction + /// + /// In en, this message translates to: + /// **'Last Interaction'** + String get lastInteraction; + + /// Action to view feedback history + /// + /// In en, this message translates to: + /// **'View History'** + String get viewFeedbackHistory; + + /// Reaction type: Like + /// + /// In en, this message translates to: + /// **'Like'** + String get reactionTypeLike; + + /// Reaction type: Insightful + /// + /// In en, this message translates to: + /// **'Insightful'** + String get reactionTypeInsightful; + + /// Reaction type: Amusing + /// + /// In en, this message translates to: + /// **'Amusing'** + String get reactionTypeAmusing; + + /// Reaction type: Sad + /// + /// In en, this message translates to: + /// **'Sad'** + String get reactionTypeSad; + + /// Reaction type: Angry + /// + /// In en, this message translates to: + /// **'Angry'** + String get reactionTypeAngry; + + /// Reaction type: Skeptical + /// + /// In en, this message translates to: + /// **'Skeptical'** + String get reactionTypeSkeptical; + + /// Initial app review feedback: Positive + /// + /// In en, this message translates to: + /// **'Positive'** + String get initialAppReviewFeedbackPositive; + + /// Initial app review feedback: Negative + /// + /// In en, this message translates to: + /// **'Negative'** + String get initialAppReviewFeedbackNegative; + + /// Action to filter community content + /// + /// In en, this message translates to: + /// **'Filter Community Content'** + String get filterCommunity; + + /// Hint text for searching by engagement user + /// + /// In en, this message translates to: + /// **'Search by user email...'** + String get searchByEngagementUser; + + /// Hint text for searching by report reporter + /// + /// In en, this message translates to: + /// **'Search by reporter email...'** + String get searchByReportReporter; + + /// Hint text for searching by app review user + /// + /// In en, this message translates to: + /// **'Search by user email...'** + String get searchByAppReviewUser; + + /// Action to select comment status + /// + /// In en, this message translates to: + /// **'Select Comment Status'** + String get selectCommentStatus; + + /// Action to select report status + /// + /// In en, this message translates to: + /// **'Select Report Status'** + String get selectReportStatus; + + /// Action to select initial feedback + /// + /// In en, this message translates to: + /// **'Select Initial Feedback'** + String get selectInitialFeedback; + + /// Action to select reportable entity + /// + /// In en, this message translates to: + /// **'Select Reported Item Type'** + String get selectReportableEntity; + + /// Reportable entity: Headline + /// + /// In en, this message translates to: + /// **'Headline'** + String get reportableEntityHeadline; + + /// Reportable entity: Source + /// + /// In en, this message translates to: + /// **'Source'** + String get reportableEntitySource; + + /// Reportable entity: Comment + /// + /// In en, this message translates to: + /// **'Comment'** + String get reportableEntityComment; + + /// Message when no engagements are found + /// + /// In en, this message translates to: + /// **'No engagements found.'** + String get noEngagementsFound; + + /// Message when no reports are found + /// + /// In en, this message translates to: + /// **'No reports found.'** + String get noReportsFound; + + /// Message when no app reviews are found + /// + /// In en, this message translates to: + /// **'No app reviews found.'** + String get noAppReviewsFound; + + /// Message when engagements are loading + /// + /// In en, this message translates to: + /// **'Loading Engagements'** + String get loadingEngagements; + + /// Message when reports are loading + /// + /// In en, this message translates to: + /// **'Loading Reports'** + String get loadingReports; + + /// Message when app reviews are loading + /// + /// In en, this message translates to: + /// **'Loading App Reviews'** + String get loadingAppReviews; + + /// Message when user ID is copied + /// + /// In en, this message translates to: + /// **'User ID copied to clipboard.'** + String get userIdCopied; + + /// Message when a report status is updated + /// + /// In en, this message translates to: + /// **'Report status updated.'** + String get reportStatusUpdated; + + /// Message displaying feedback history for a user + /// + /// In en, this message translates to: + /// **'Feedback History for {email}'** + String feedbackHistoryForUser(String email); + + /// Message when no feedback history is available + /// + /// In en, this message translates to: + /// **'No feedback history available for this user.'** + String get noFeedbackHistory; + + /// Message displaying the date feedback was provided + /// + /// In en, this message translates to: + /// **'Feedback provided at: {date}'** + String feedbackProvidedAt(String date); + + /// Message displaying the reason for feedback + /// + /// In en, this message translates to: + /// **'Reason: {reason}'** + String feedbackReason(String reason); + + /// Message when no reason for feedback is provided + /// + /// In en, this message translates to: + /// **'No reason provided.'** + String get noReasonProvided; + + /// A generic 'Yes' response. + /// + /// In en, this message translates to: + /// **'Yes'** + String get yes; + + /// A generic 'No' response. + /// + /// In en, this message translates to: + /// **'No'** + String get no; + + /// Report reason: The content is misinformation or fake news. + /// + /// In en, this message translates to: + /// **'Misinformation / Fake News'** + String get reportReasonMisinformationOrFakeNews; + + /// Report reason: The headline is clickbait. + /// + /// In en, this message translates to: + /// **'Clickbait Title'** + String get reportReasonClickbaitTitle; + + /// Report reason: The content is offensive or hate speech. + /// + /// In en, this message translates to: + /// **'Offensive / Hate Speech'** + String get reportReasonOffensiveOrHateSpeech; + + /// Report reason: The content is spam or a scam. + /// + /// In en, this message translates to: + /// **'Spam / Scam'** + String get reportReasonSpamOrScam; + + /// Report reason: The link in the content is broken. + /// + /// In en, this message translates to: + /// **'Broken Link'** + String get reportReasonBrokenLink; + + /// Report reason: The content is behind a paywall. + /// + /// In en, this message translates to: + /// **'Paywalled'** + String get reportReasonPaywalled; + + /// Report reason: The source exhibits low-quality journalism. + /// + /// In en, this message translates to: + /// **'Low Quality Journalism'** + String get reportReasonLowQualityJournalism; + + /// Report reason: The source has a high density of ads. + /// + /// In en, this message translates to: + /// **'High Ad Density'** + String get reportReasonHighAdDensity; + + /// Report reason: The source is a blog. + /// + /// In en, this message translates to: + /// **'Blog'** + String get reportReasonBlog; + + /// Report reason: The source is a government entity. + /// + /// In en, this message translates to: + /// **'Government Source'** + String get reportReasonGovernmentSource; + + /// Report reason: The source is a news aggregator. + /// + /// In en, this message translates to: + /// **'Aggregator'** + String get reportReasonAggregator; + + /// Report reason: Other, not specified. + /// + /// In en, this message translates to: + /// **'Other'** + String get reportReasonOther; + + /// Report reason: The source frequently uses paywalls. + /// + /// In en, this message translates to: + /// **'Frequent Paywalls'** + String get reportReasonFrequentPaywalls; + + /// Report reason: The source is impersonating another entity. + /// + /// In en, this message translates to: + /// **'Impersonation'** + String get reportReasonImpersonation; + + /// Message displayed in the feedback history dialog when there is no history. + /// + /// In en, this message translates to: + /// **'No negative feedback history found for this user.'** + String get noNegativeFeedbackHistory; + + /// Confirmation button text for a reject action. + /// + /// In en, this message translates to: + /// **'Reject'** + String get reject; + + /// Admin-centric status for a comment automatically flagged by AI. + /// + /// In en, this message translates to: + /// **'Flagged by AI'** + String get commentStatusFlaggedByAi; + + /// A generic 'Cancel' button text. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// A simplified confirmation message shown to an admin before they reject and delete a user's comment. + /// + /// In en, this message translates to: + /// **'Are you sure you want to reject and permanently delete this comment? This action cannot be undone.'** + String get rejectCommentConfirmation; + + /// Hint text for the search input field in filter dialogs, specifying to search by User ID. + /// + /// In en, this message translates to: + /// **'Search by User ID...'** + String get searchByUserId; + + /// Action to view the reported headline + /// + /// In en, this message translates to: + /// **'View Headline'** + String get viewReportedHeadline; + + /// Action to view the reported source + /// + /// In en, this message translates to: + /// **'View Source'** + String get viewReportedSource; + + /// Action to view the reported comment + /// + /// In en, this message translates to: + /// **'View Comment'** + String get viewReportedComment; + + /// Column header for the type of entity being reported + /// + /// In en, this message translates to: + /// **'Entity Type'** + String get entityType; + + /// Column header for user feedback on app reviews. + /// + /// In en, this message translates to: + /// **'Feedback'** + String get feedback; + + /// Title for the dialog showing detailed user feedback. + /// + /// In en, this message translates to: + /// **'Feedback Details'** + String get feedbackDetails; + + /// Moderation status: The item is awaiting review. + /// + /// In en, this message translates to: + /// **'Pending Review'** + String get moderationStatusPendingReview; + + /// Moderation status: A decision has been made on the item. + /// + /// In en, this message translates to: + /// **'Resolved'** + String get moderationStatusResolved; + + /// Label for a filter option to show items that have a comment. + /// + /// In en, this message translates to: + /// **'Has Comment'** + String get hasComment; + + /// Filter option to show items regardless of a certain property (e.g., show items with or without comments). + /// + /// In en, this message translates to: + /// **'Any'** + String get any; + + /// Filter option to show only items that have a comment. + /// + /// In en, this message translates to: + /// **'With Comment'** + String get withComment; + + /// Filter option to show only items that do not have a comment. + /// + /// In en, this message translates to: + /// **'Without Comment'** + String get withoutComment; + + /// No description provided for @reportResolved. + /// + /// In en, this message translates to: + /// **'Report resolved.'** + String get reportResolved; + + /// Message when a comment is approved + /// + /// In en, this message translates to: + /// **'Comment approved.'** + String get commentApproved; + + /// Message when a comment is rejected + /// + /// In en, this message translates to: + /// **'Comment rejected.'** + String get commentRejected; + + /// Menu item text to copy the ID of a headline. + /// + /// In en, this message translates to: + /// **'Copy Headline ID'** + String get copyHeadlineId; + + /// Menu item text to copy the ID of a reported item. + /// + /// In en, this message translates to: + /// **'Copy Reported Item ID'** + String get copyReportedItemId; + + /// Tooltip for the button to view feedback details. + /// + /// In en, this message translates to: + /// **'View Feedback Details'** + String get viewFeedbackDetails; + + /// Title for the dialog showing the details of a user-submitted report. + /// + /// In en, this message translates to: + /// **'Report Details'** + String get reportDetails; + + /// Title for the dialog showing the details of a user-submitted comment. + /// + /// In en, this message translates to: + /// **'Comment Details'** + String get commentDetails; + + /// Label for the Community Management page title and navigation. + /// + /// In en, this message translates to: + /// **'Community Management'** + String get communityManagement; + + /// Short navigation label for Content Management. + /// + /// In en, this message translates to: + /// **'Content'** + String get navContent; + + /// Short navigation label for User Management. + /// + /// In en, this message translates to: + /// **'Users'** + String get navUsers; + + /// Short navigation label for Community Management. + /// + /// In en, this message translates to: + /// **'Community'** + String get navCommunity; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 345786b8..d34a8ec4 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -1313,10 +1313,10 @@ class AppLocalizationsAr extends AppLocalizations { String get searchByHeadlineTitle => 'البحث بعنوان الخبر...'; @override - String get searchByTopicName => 'البحث باسم الموضوع...'; + String get searchByTopicName => 'البحث بالاسم أو المعرف...'; @override - String get searchBySourceName => 'البحث باسم المصدر...'; + String get searchBySourceName => 'البحث بالاسم أو المعرف...'; @override String get selectSources => 'اختر المصادر'; @@ -1396,7 +1396,7 @@ class AppLocalizationsAr extends AppLocalizations { String get filterUsers => 'تصفية المستخدمين'; @override - String get searchByUserEmail => 'البحث بالبريد الإلكتروني للمستخدم...'; + String get searchByUserEmail => 'البحث بالبريد الإلكتروني أو المعرف...'; @override String get selectAppRoles => 'اختر أدوار التطبيق'; @@ -1802,4 +1802,326 @@ class AppLocalizationsAr extends AppLocalizations { @override String get enableCommunityFeaturesDescription => 'ينشط أو يعطل عالميًا جميع الوظائف المتعلقة بالمجتمع، بما في ذلك المشاركة والإبلاغ.'; + + @override + String get communityManagementPageDescription => + 'إدارة المحتوى الذي ينشئه المستخدمون بما في ذلك التفاعلات (ردود الفعل والتعليقات) وتبليغات المحتوى ومراجعات التطبيق.'; + + @override + String get engagements => 'التفاعلات'; + + @override + String get reports => 'التبليغات'; + + @override + String get appReviews => 'مراجعات التطبيق'; + + @override + String get user => 'المستخدم'; + + @override + String get engagedContent => 'المحتوى المتفاعل معه'; + + @override + String get reaction => 'رد الفعل'; + + @override + String get comment => 'التعليق'; + + @override + String get commentStatus => 'حالة التعليق'; + + @override + String get date => 'التاريخ'; + + @override + String get approveComment => 'الموافقة على التعليق'; + + @override + String get rejectComment => 'رفض التعليق'; + + @override + String get viewEngagedContent => 'عرض المحتوى'; + + @override + String get copyUserId => 'نسخ معرف المستخدم'; + + @override + String get reporter => 'المبلغ'; + + @override + String get reportedItem => 'العنصر المبلغ عنه'; + + @override + String get reason => 'السبب'; + + @override + String get reportStatus => 'حالة البلاغ'; + + @override + String get viewReportedItem => 'عرض العنصر'; + + @override + String get markAsInReview => 'وضع علامة \'قيد المراجعة\''; + + @override + String get resolveReport => 'حل البلاغ'; + + @override + String get initialFeedback => 'التقييم الأولي'; + + @override + String get osPromptRequested => 'طُلب تقييم النظام؟'; + + @override + String get feedbackHistory => 'سجل التقييمات'; + + @override + String get lastInteraction => 'آخر تفاعل'; + + @override + String get viewFeedbackHistory => 'عرض السجل'; + + @override + String get reactionTypeLike => 'إعجاب'; + + @override + String get reactionTypeInsightful => 'ثاقب'; + + @override + String get reactionTypeAmusing => 'مسلي'; + + @override + String get reactionTypeSad => 'حزين'; + + @override + String get reactionTypeAngry => 'غاضب'; + + @override + String get reactionTypeSkeptical => 'متشكك'; + + @override + String get initialAppReviewFeedbackPositive => 'إيجابي'; + + @override + String get initialAppReviewFeedbackNegative => 'سلبي'; + + @override + String get filterCommunity => 'تصفية محتوى المجتمع'; + + @override + String get searchByEngagementUser => 'البحث بالبريد الإلكتروني للمستخدم...'; + + @override + String get searchByReportReporter => 'البحث بالبريد الإلكتروني للمبلغ...'; + + @override + String get searchByAppReviewUser => 'البحث بالبريد الإلكتروني للمستخدم...'; + + @override + String get selectCommentStatus => 'اختر حالة التعليق'; + + @override + String get selectReportStatus => 'اختر حالة البلاغ'; + + @override + String get selectInitialFeedback => 'اختر التقييم الأولي'; + + @override + String get selectReportableEntity => 'اختر نوع العنصر المبلغ عنه'; + + @override + String get reportableEntityHeadline => 'عنوان'; + + @override + String get reportableEntitySource => 'مصدر'; + + @override + String get reportableEntityComment => 'تعليق'; + + @override + String get noEngagementsFound => 'لم يتم العثور على تفاعلات.'; + + @override + String get noReportsFound => 'لم يتم العثور على بلاغات.'; + + @override + String get noAppReviewsFound => 'لم يتم العثور على مراجعات للتطبيق.'; + + @override + String get loadingEngagements => 'جاري تحميل التفاعلات'; + + @override + String get loadingReports => 'جاري تحميل البلاغات'; + + @override + String get loadingAppReviews => 'جاري تحميل مراجعات التطبيق'; + + @override + String get userIdCopied => 'تم نسخ معرف المستخدم إلى الحافظة.'; + + @override + String get reportStatusUpdated => 'تم تحديث حالة البلاغ.'; + + @override + String feedbackHistoryForUser(String email) { + return 'سجل التقييمات للمستخدم $email'; + } + + @override + String get noFeedbackHistory => 'لا يوجد سجل تقييمات متاح لهذا المستخدم.'; + + @override + String feedbackProvidedAt(String date) { + return 'تم تقديم التقييم في: $date'; + } + + @override + String feedbackReason(String reason) { + return 'السبب: $reason'; + } + + @override + String get noReasonProvided => 'لم يتم تقديم سبب.'; + + @override + String get yes => 'نعم'; + + @override + String get no => 'لا'; + + @override + String get reportReasonMisinformationOrFakeNews => + 'معلومات مضللة / أخبار كاذبة'; + + @override + String get reportReasonClickbaitTitle => 'عنوان مضلل'; + + @override + String get reportReasonOffensiveOrHateSpeech => 'محتوى مسيء / خطاب كراهية'; + + @override + String get reportReasonSpamOrScam => 'بريد مزعج / احتيال'; + + @override + String get reportReasonBrokenLink => 'رابط معطل'; + + @override + String get reportReasonPaywalled => 'يتطلب اشتراكًا مدفوعًا'; + + @override + String get reportReasonLowQualityJournalism => 'صحافة منخفضة الجودة'; + + @override + String get reportReasonHighAdDensity => 'كثافة إعلانات عالية'; + + @override + String get reportReasonBlog => 'مدونة'; + + @override + String get reportReasonGovernmentSource => 'مصدر حكومي'; + + @override + String get reportReasonAggregator => 'مجمع أخبار'; + + @override + String get reportReasonOther => 'آخر'; + + @override + String get reportReasonFrequentPaywalls => 'اشتراكات مدفوعة متكررة'; + + @override + String get reportReasonImpersonation => 'انتحال شخصية'; + + @override + String get noNegativeFeedbackHistory => + 'لم يتم العثور على سجل تقييمات سلبية لهذا المستخدم.'; + + @override + String get reject => 'رفض'; + + @override + String get commentStatusFlaggedByAi => 'تم الإبلاغ بواسطة الذكاء الاصطناعي'; + + @override + String get cancel => 'إلغاء'; + + @override + String get rejectCommentConfirmation => + 'هل أنت متأكد أنك تريد رفض وحذف هذا التعليق نهائيًا؟ لا يمكن التراجع عن هذا الإجراء.'; + + @override + String get searchByUserId => 'ابحث باستخدام معرّف المستخدم...'; + + @override + String get viewReportedHeadline => 'عرض العنوان'; + + @override + String get viewReportedSource => 'عرض المصدر'; + + @override + String get viewReportedComment => 'عرض التعليق'; + + @override + String get entityType => 'نوع الكيان'; + + @override + String get feedback => 'التقييم'; + + @override + String get feedbackDetails => 'تفاصيل التقييم'; + + @override + String get moderationStatusPendingReview => 'قيد المراجعة'; + + @override + String get moderationStatusResolved => 'تم الحل'; + + @override + String get hasComment => 'يحتوي على تعليق'; + + @override + String get any => 'الكل'; + + @override + String get withComment => 'مع تعليق'; + + @override + String get withoutComment => 'بدون تعليق'; + + @override + String get reportResolved => 'تم حل البلاغ.'; + + @override + String get commentApproved => 'تمت الموافقة على التعليق.'; + + @override + String get commentRejected => 'تم رفض التعليق.'; + + @override + String get copyHeadlineId => 'نسخ معرّف العنوان'; + + @override + String get copyReportedItemId => 'نسخ معرّف العنصر المُبلغ عنه'; + + @override + String get viewFeedbackDetails => 'عرض تفاصيل التقييم'; + + @override + String get reportDetails => 'تفاصيل البلاغ'; + + @override + String get commentDetails => 'تفاصيل التعليق'; + + @override + String get communityManagement => 'إدارة المجتمع'; + + @override + String get navContent => 'المحتوى'; + + @override + String get navUsers => 'المستخدمون'; + + @override + String get navCommunity => 'المجتمع'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index caf6510a..3c5adaf9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1315,10 +1315,10 @@ class AppLocalizationsEn extends AppLocalizations { String get searchByHeadlineTitle => 'Search by headline title...'; @override - String get searchByTopicName => 'Search by topic name...'; + String get searchByTopicName => 'Search by Name or ID...'; @override - String get searchBySourceName => 'Search by source name...'; + String get searchBySourceName => 'Search by Name or ID...'; @override String get selectSources => 'Select Sources'; @@ -1398,7 +1398,7 @@ class AppLocalizationsEn extends AppLocalizations { String get filterUsers => 'Filter Users'; @override - String get searchByUserEmail => 'Search by user email...'; + String get searchByUserEmail => 'Search by Email or ID...'; @override String get selectAppRoles => 'Select App Roles'; @@ -1807,4 +1807,327 @@ class AppLocalizationsEn extends AppLocalizations { @override String get enableCommunityFeaturesDescription => 'Globally activates or deactivates all community-related functionality, including engagement and reporting.'; + + @override + String get communityManagementPageDescription => + 'Manage user-generated content including engagements (reactions and comments), content reports, and app reviews.'; + + @override + String get engagements => 'Engagements'; + + @override + String get reports => 'Reports'; + + @override + String get appReviews => 'App Reviews'; + + @override + String get user => 'User'; + + @override + String get engagedContent => 'Engaged Content'; + + @override + String get reaction => 'Reaction'; + + @override + String get comment => 'Comment'; + + @override + String get commentStatus => 'Comment Status'; + + @override + String get date => 'Date'; + + @override + String get approveComment => 'Approve Comment'; + + @override + String get rejectComment => 'Reject Comment'; + + @override + String get viewEngagedContent => 'View Content'; + + @override + String get copyUserId => 'Copy User ID'; + + @override + String get reporter => 'Reporter'; + + @override + String get reportedItem => 'Reported Item'; + + @override + String get reason => 'Reason'; + + @override + String get reportStatus => 'Report Status'; + + @override + String get viewReportedItem => 'View Item'; + + @override + String get markAsInReview => 'Mark as In Review'; + + @override + String get resolveReport => 'Resolve Report'; + + @override + String get initialFeedback => 'Initial Feedback'; + + @override + String get osPromptRequested => 'OS Prompt?'; + + @override + String get feedbackHistory => 'Feedback History'; + + @override + String get lastInteraction => 'Last Interaction'; + + @override + String get viewFeedbackHistory => 'View History'; + + @override + String get reactionTypeLike => 'Like'; + + @override + String get reactionTypeInsightful => 'Insightful'; + + @override + String get reactionTypeAmusing => 'Amusing'; + + @override + String get reactionTypeSad => 'Sad'; + + @override + String get reactionTypeAngry => 'Angry'; + + @override + String get reactionTypeSkeptical => 'Skeptical'; + + @override + String get initialAppReviewFeedbackPositive => 'Positive'; + + @override + String get initialAppReviewFeedbackNegative => 'Negative'; + + @override + String get filterCommunity => 'Filter Community Content'; + + @override + String get searchByEngagementUser => 'Search by user email...'; + + @override + String get searchByReportReporter => 'Search by reporter email...'; + + @override + String get searchByAppReviewUser => 'Search by user email...'; + + @override + String get selectCommentStatus => 'Select Comment Status'; + + @override + String get selectReportStatus => 'Select Report Status'; + + @override + String get selectInitialFeedback => 'Select Initial Feedback'; + + @override + String get selectReportableEntity => 'Select Reported Item Type'; + + @override + String get reportableEntityHeadline => 'Headline'; + + @override + String get reportableEntitySource => 'Source'; + + @override + String get reportableEntityComment => 'Comment'; + + @override + String get noEngagementsFound => 'No engagements found.'; + + @override + String get noReportsFound => 'No reports found.'; + + @override + String get noAppReviewsFound => 'No app reviews found.'; + + @override + String get loadingEngagements => 'Loading Engagements'; + + @override + String get loadingReports => 'Loading Reports'; + + @override + String get loadingAppReviews => 'Loading App Reviews'; + + @override + String get userIdCopied => 'User ID copied to clipboard.'; + + @override + String get reportStatusUpdated => 'Report status updated.'; + + @override + String feedbackHistoryForUser(String email) { + return 'Feedback History for $email'; + } + + @override + String get noFeedbackHistory => + 'No feedback history available for this user.'; + + @override + String feedbackProvidedAt(String date) { + return 'Feedback provided at: $date'; + } + + @override + String feedbackReason(String reason) { + return 'Reason: $reason'; + } + + @override + String get noReasonProvided => 'No reason provided.'; + + @override + String get yes => 'Yes'; + + @override + String get no => 'No'; + + @override + String get reportReasonMisinformationOrFakeNews => + 'Misinformation / Fake News'; + + @override + String get reportReasonClickbaitTitle => 'Clickbait Title'; + + @override + String get reportReasonOffensiveOrHateSpeech => 'Offensive / Hate Speech'; + + @override + String get reportReasonSpamOrScam => 'Spam / Scam'; + + @override + String get reportReasonBrokenLink => 'Broken Link'; + + @override + String get reportReasonPaywalled => 'Paywalled'; + + @override + String get reportReasonLowQualityJournalism => 'Low Quality Journalism'; + + @override + String get reportReasonHighAdDensity => 'High Ad Density'; + + @override + String get reportReasonBlog => 'Blog'; + + @override + String get reportReasonGovernmentSource => 'Government Source'; + + @override + String get reportReasonAggregator => 'Aggregator'; + + @override + String get reportReasonOther => 'Other'; + + @override + String get reportReasonFrequentPaywalls => 'Frequent Paywalls'; + + @override + String get reportReasonImpersonation => 'Impersonation'; + + @override + String get noNegativeFeedbackHistory => + 'No negative feedback history found for this user.'; + + @override + String get reject => 'Reject'; + + @override + String get commentStatusFlaggedByAi => 'Flagged by AI'; + + @override + String get cancel => 'Cancel'; + + @override + String get rejectCommentConfirmation => + 'Are you sure you want to reject and permanently delete this comment? This action cannot be undone.'; + + @override + String get searchByUserId => 'Search by User ID...'; + + @override + String get viewReportedHeadline => 'View Headline'; + + @override + String get viewReportedSource => 'View Source'; + + @override + String get viewReportedComment => 'View Comment'; + + @override + String get entityType => 'Entity Type'; + + @override + String get feedback => 'Feedback'; + + @override + String get feedbackDetails => 'Feedback Details'; + + @override + String get moderationStatusPendingReview => 'Pending Review'; + + @override + String get moderationStatusResolved => 'Resolved'; + + @override + String get hasComment => 'Has Comment'; + + @override + String get any => 'Any'; + + @override + String get withComment => 'With Comment'; + + @override + String get withoutComment => 'Without Comment'; + + @override + String get reportResolved => 'Report resolved.'; + + @override + String get commentApproved => 'Comment approved.'; + + @override + String get commentRejected => 'Comment rejected.'; + + @override + String get copyHeadlineId => 'Copy Headline ID'; + + @override + String get copyReportedItemId => 'Copy Reported Item ID'; + + @override + String get viewFeedbackDetails => 'View Feedback Details'; + + @override + String get reportDetails => 'Report Details'; + + @override + String get commentDetails => 'Comment Details'; + + @override + String get communityManagement => 'Community Management'; + + @override + String get navContent => 'Content'; + + @override + String get navUsers => 'Users'; + + @override + String get navCommunity => 'Community'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c9059458..c1651171 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1654,11 +1654,11 @@ "@searchByHeadlineTitle": { "description": "نص تلميح حقل البحث عن العناوين." }, - "searchByTopicName": "البحث باسم الموضوع...", + "searchByTopicName": "البحث بالاسم أو المعرف...", "@searchByTopicName": { "description": "نص تلميح حقل البحث عن المواضيع." }, - "searchBySourceName": "البحث باسم المصدر...", + "searchBySourceName": "البحث بالاسم أو المعرف...", "@searchBySourceName": { "description": "نص تلميح حقل البحث عن المصادر." }, @@ -1766,7 +1766,7 @@ "@filterUsers": { "description": "عنوان مربع حوار التصفية عند تصفية المستخدمين." }, - "searchByUserEmail": "البحث بالبريد الإلكتروني للمستخدم...", + "searchByUserEmail": "البحث بالبريد الإلكتروني أو المعرف...", "@searchByUserEmail": { "description": "نص تلميح حقل البحث عن المستخدم." }, @@ -2249,5 +2249,434 @@ "enableCommunityFeaturesDescription": "ينشط أو يعطل عالميًا جميع الوظائف المتعلقة بالمجتمع، بما في ذلك المشاركة والإبلاغ.", "@enableCommunityFeaturesDescription": { "description": "وصف المفتاح الرئيسي لتفعيل جميع ميزات المجتمع." + }, + "communityManagementPageDescription": "إدارة المحتوى الذي ينشئه المستخدمون بما في ذلك التفاعلات (ردود الفعل والتعليقات) وتبليغات المحتوى ومراجعات التطبيق.", + "@communityManagementPageDescription": { + "description": "وصف صفحة إدارة المجتمع" + }, + "engagements": "التفاعلات", + "@engagements": { + "description": "تسمية الصفحة الفرعية للتفاعلات" + }, + "reports": "التبليغات", + "@reports": { + "description": "تسمية الصفحة الفرعية لالتبليغات" + }, + "appReviews": "مراجعات التطبيق", + "@appReviews": { + "description": "تسمية الصفحة الفرعية لمراجعات التطبيق" + }, + "user": "المستخدم", + "@user": { + "description": "رأس العمود للمستخدم" + }, + "engagedContent": "المحتوى المتفاعل معه", + "@engagedContent": { + "description": "رأس العمود للمحتوى المتفاعل معه" + }, + "reaction": "رد الفعل", + "@reaction": { + "description": "رأس العمود لرد الفعل" + }, + "comment": "التعليق", + "@comment": { + "description": "رأس العمود للتعليق" + }, + "commentStatus": "حالة التعليق", + "@commentStatus": { + "description": "رأس العمود لحالة التعليق" + }, + "date": "التاريخ", + "@date": { + "description": "رأس العمود للتاريخ" + }, + "approveComment": "الموافقة على التعليق", + "@approveComment": { + "description": "إجراء للموافقة على تعليق" + }, + "rejectComment": "رفض التعليق", + "@rejectComment": { + "description": "إجراء لرفض تعليق" + }, + "viewEngagedContent": "عرض المحتوى", + "@viewEngagedContent": { + "description": "إجراء لعرض المحتوى المتفاعل معه" + }, + "copyUserId": "نسخ معرف المستخدم", + "@copyUserId": { + "description": "إجراء لنسخ معرف المستخدم" + }, + "reporter": "المبلغ", + "@reporter": { + "description": "رأس العمود للمبلغ" + }, + "reportedItem": "العنصر المبلغ عنه", + "@reportedItem": { + "description": "رأس العمود للعنصر المبلغ عنه" + }, + "reason": "السبب", + "@reason": { + "description": "رأس العمود للسبب" + }, + "reportStatus": "حالة البلاغ", + "@reportStatus": { + "description": "رأس العمود لحالة البلاغ" + }, + "viewReportedItem": "عرض العنصر", + "@viewReportedItem": { + "description": "إجراء لعرض العنصر المبلغ عنه" + }, + "markAsInReview": "وضع علامة 'قيد المراجعة'", + "@markAsInReview": { + "description": "إجراء لوضع علامة على بلاغ بأنه قيد المراجعة" + }, + "resolveReport": "حل البلاغ", + "@resolveReport": { + "description": "إجراء لحل بلاغ" + }, + "initialFeedback": "التقييم الأولي", + "@initialFeedback": { + "description": "رأس العمود للتقييم الأولي" + }, + "osPromptRequested": "طُلب تقييم النظام؟", + "@osPromptRequested": { + "description": "رأس العمود لطلب تقييم النظام" + }, + "feedbackHistory": "سجل التقييمات", + "@feedbackHistory": { + "description": "رأس العمود لسجل التقييمات" + }, + "lastInteraction": "آخر تفاعل", + "@lastInteraction": { + "description": "رأس العمود لآخر تفاعل" + }, + "viewFeedbackHistory": "عرض السجل", + "@viewFeedbackHistory": { + "description": "إجراء لعرض سجل التقييمات" + }, + "reactionTypeLike": "إعجاب", + "@reactionTypeLike": { + "description": "نوع رد الفعل: إعجاب" + }, + "reactionTypeInsightful": "ثاقب", + "@reactionTypeInsightful": { + "description": "نوع رد الفعل: ثاقب" + }, + "reactionTypeAmusing": "مسلي", + "@reactionTypeAmusing": { + "description": "نوع رد الفعل: مسلي" + }, + "reactionTypeSad": "حزين", + "@reactionTypeSad": { + "description": "نوع رد الفعل: حزين" + }, + "reactionTypeAngry": "غاضب", + "@reactionTypeAngry": { + "description": "نوع رد الفعل: غاضب" + }, + "reactionTypeSkeptical": "متشكك", + "@reactionTypeSkeptical": { + "description": "نوع رد الفعل: متشكك" + }, + "initialAppReviewFeedbackPositive": "إيجابي", + "@initialAppReviewFeedbackPositive": { + "description": "التقييم الأولي للتطبيق: إيجابي" + }, + "initialAppReviewFeedbackNegative": "سلبي", + "@initialAppReviewFeedbackNegative": { + "description": "التقييم الأولي للتطبيق: سلبي" + }, + "filterCommunity": "تصفية محتوى المجتمع", + "@filterCommunity": { + "description": "إجراء لتصفية محتوى المجتمع" + }, + "searchByEngagementUser": "البحث بالبريد الإلكتروني للمستخدم...", + "@searchByEngagementUser": { + "description": "تلميح للبحث عن طريق البريد الإلكتروني للمستخدم" + }, + "searchByReportReporter": "البحث بالبريد الإلكتروني للمبلغ...", + "@searchByReportReporter": { + "description": "تلميح للبحث عن طريق البريد الإلكتروني للمبلغ" + }, + "searchByAppReviewUser": "البحث بالبريد الإلكتروني للمستخدم...", + "@searchByAppReviewUser": { + "description": "تلميح للبحث عن طريق البريد الإلكتروني لمستخدم مراجعة التطبيق" + }, + "selectCommentStatus": "اختر حالة التعليق", + "@selectCommentStatus": { + "description": "إجراء لاختيار حالة التعليق" + }, + "selectReportStatus": "اختر حالة البلاغ", + "@selectReportStatus": { + "description": "إجراء لاختيار حالة البلاغ" + }, + "selectInitialFeedback": "اختر التقييم الأولي", + "@selectInitialFeedback": { + "description": "إجراء لاختيار التقييم الأولي" + }, + "selectReportableEntity": "اختر نوع العنصر المبلغ عنه", + "@selectReportableEntity": { + "description": "إجراء لاختيار نوع العنصر المبلغ عنه" + }, + "reportableEntityHeadline": "عنوان", + "@reportableEntityHeadline": { + "description": "العنصر القابل للإبلاغ عنه: عنوان" + }, + "reportableEntitySource": "مصدر", + "@reportableEntitySource": { + "description": "العنصر القابل للإبلاغ عنه: مصدر" + }, + "reportableEntityComment": "تعليق", + "@reportableEntityComment": { + "description": "العنصر القابل للإبلاغ عنه: تعليق" + }, + "noEngagementsFound": "لم يتم العثور على تفاعلات.", + "@noEngagementsFound": { + "description": "رسالة عند عدم العثور على تفاعلات" + }, + "noReportsFound": "لم يتم العثور على بلاغات.", + "@noReportsFound": { + "description": "رسالة عند عدم العثور على بلاغات" + }, + "noAppReviewsFound": "لم يتم العثور على مراجعات للتطبيق.", + "@noAppReviewsFound": { + "description": "رسالة عند عدم العثور على مراجعات للتطبيق" + }, + "loadingEngagements": "جاري تحميل التفاعلات", + "@loadingEngagements": { + "description": "رسالة عند تحميل التفاعلات" + }, + "loadingReports": "جاري تحميل البلاغات", + "@loadingReports": { + "description": "رسالة عند تحميل البلاغات" + }, + "loadingAppReviews": "جاري تحميل مراجعات التطبيق", + "@loadingAppReviews": { + "description": "رسالة عند تحميل مراجعات التطبيق" + }, + "userIdCopied": "تم نسخ معرف المستخدم إلى الحافظة.", + "@userIdCopied": { + "description": "رسالة عند نسخ معرف المستخدم" + }, + "reportStatusUpdated": "تم تحديث حالة البلاغ.", + "@reportStatusUpdated": { + "description": "رسالة عند تحديث حالة البلاغ" + }, + "feedbackHistoryForUser": "سجل التقييمات للمستخدم {email}", + "@feedbackHistoryForUser": { + "description": "رسالة تعرض سجل التقييمات لمستخدم", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "noFeedbackHistory": "لا يوجد سجل تقييمات متاح لهذا المستخدم.", + "@noFeedbackHistory": { + "description": "رسالة عند عدم توفر سجل تقييمات" + }, + "feedbackProvidedAt": "تم تقديم التقييم في: {date}", + "@feedbackProvidedAt": { + "description": "رسالة تعرض تاريخ تقديم التقييم", + "placeholders": { + "date": { + "type": "String" + } + } + }, + "feedbackReason": "السبب: {reason}", + "@feedbackReason": { + "description": "رسالة تعرض سبب التقييم", + "placeholders": { + "reason": { + "type": "String" + } + } + }, + "noReasonProvided": "لم يتم تقديم سبب.", + "@noReasonProvided": { + "description": "رسالة عند عدم تقديم سبب للتقييم" + }, + "yes": "نعم", + "@yes": { + "description": "رد 'نعم' عام." + }, + "no": "لا", + "@no": { + "description": "رد 'لا' عام." + }, + "reportReasonMisinformationOrFakeNews": "معلومات مضللة / أخبار كاذبة", + "@reportReasonMisinformationOrFakeNews": { + "description": "سبب البلاغ: المحتوى معلومات مضللة أو أخبار كاذبة." + }, + "reportReasonClickbaitTitle": "عنوان مضلل", + "@reportReasonClickbaitTitle": { + "description": "سبب البلاغ: العنوان الرئيسي مضلل." + }, + "reportReasonOffensiveOrHateSpeech": "محتوى مسيء / خطاب كراهية", + "@reportReasonOffensiveOrHateSpeech": { + "description": "سبب البلاغ: المحتوى مسيء أو يتضمن خطاب كراهية." + }, + "reportReasonSpamOrScam": "بريد مزعج / احتيال", + "@reportReasonSpamOrScam": { + "description": "سبب البلاغ: المحتوى بريد مزعج أو احتيال." + }, + "reportReasonBrokenLink": "رابط معطل", + "@reportReasonBrokenLink": { + "description": "سبب البلاغ: الرابط في المحتوى معطل." + }, + "reportReasonPaywalled": "يتطلب اشتراكًا مدفوعًا", + "@reportReasonPaywalled": { + "description": "سبب البلاغ: المحتوى يتطلب اشتراكًا مدفوعًا." + }, + "reportReasonLowQualityJournalism": "صحافة منخفضة الجودة", + "@reportReasonLowQualityJournalism": { + "description": "سبب البلاغ: المصدر يقدم صحافة منخفضة الجودة." + }, + "reportReasonHighAdDensity": "كثافة إعلانات عالية", + "@reportReasonHighAdDensity": { + "description": "سبب البلاغ: المصدر يحتوي على كثافة عالية من الإعلانات." + }, + "reportReasonBlog": "مدونة", + "@reportReasonBlog": { + "description": "سبب البلاغ: المصدر مدونة." + }, + "reportReasonGovernmentSource": "مصدر حكومي", + "@reportReasonGovernmentSource": { + "description": "سبب البلاغ: المصدر جهة حكومية." + }, + "reportReasonAggregator": "مجمع أخبار", + "@reportReasonAggregator": { + "description": "سبب البلاغ: المصدر مجمع أخبار." + }, + "reportReasonOther": "آخر", + "@reportReasonOther": { + "description": "سبب البلاغ: آخر، غير محدد." + }, + "reportReasonFrequentPaywalls": "اشتراكات مدفوعة متكررة", + "@reportReasonFrequentPaywalls": { + "description": "سبب البلاغ: المصدر يستخدم اشتراكات مدفوعة بشكل متكرر." + }, + "reportReasonImpersonation": "انتحال شخصية", + "@reportReasonImpersonation": { + "description": "سبب البلاغ: المصدر ينتحل شخصية جهة أخرى." + }, + "noNegativeFeedbackHistory": "لم يتم العثور على سجل تقييمات سلبية لهذا المستخدم.", + "@noNegativeFeedbackHistory": { + "description": "رسالة تظهر في مربع حوار سجل التقييمات عند عدم وجود سجل." + }, + "reject": "رفض", + "@reject": { + "description": "نص زر التأكيد لإجراء الرفض." + }, + "commentStatusFlaggedByAi": "تم الإبلاغ بواسطة الذكاء الاصطناعي", + "@commentStatusFlaggedByAi": { + "description": "حالة إدارية لتعليق تم الإبلاغ عنه تلقائيًا بواسطة الذكاء الاصطناعي." + }, + "cancel": "إلغاء", + "@cancel": { + "description": "A generic 'Cancel' button text." + }, + "rejectCommentConfirmation": "هل أنت متأكد أنك تريد رفض وحذف هذا التعليق نهائيًا؟ لا يمكن التراجع عن هذا الإجراء.", + "@rejectCommentConfirmation": { + "description": "A simplified confirmation message shown to an admin before they reject and delete a user's comment." + }, + "searchByUserId": "ابحث باستخدام معرّف المستخدم...", + "@searchByUserId": { + "description": "Hint text for the search input field in filter dialogs, specifying to search by User ID." + }, + "viewReportedHeadline": "عرض العنوان", + "@viewReportedHeadline": { + "description": "Action to view the reported headline" + }, + "viewReportedSource": "عرض المصدر", + "@viewReportedSource": { + "description": "Action to view the reported source" + }, + "viewReportedComment": "عرض التعليق", + "@viewReportedComment": { + "description": "Action to view the reported comment" + }, + "entityType": "نوع الكيان", + "@entityType": { + "description": "Column header for the type of entity being reported" + }, + "feedback": "التقييم", + "@feedback": { + "description": "Column header for user feedback on app reviews." + }, + "feedbackDetails": "تفاصيل التقييم", + "@feedbackDetails": { + "description": "Title for the dialog showing detailed user feedback." + }, + "moderationStatusPendingReview": "قيد المراجعة", + "@moderationStatusPendingReview": { + "description": "Moderation status: The item is awaiting review." + }, + "moderationStatusResolved": "تم الحل", + "@moderationStatusResolved": { + "description": "Moderation status: A decision has been made on the item." + }, + "hasComment": "يحتوي على تعليق", + "@hasComment": { + "description": "تسمية لخيار تصفية لإظهار العناصر التي تحتوي على تعليق." + }, + "any": "الكل", + "@any": { + "description": "خيار تصفية لإظهار العناصر بغض النظر عن خاصية معينة (على سبيل المثال، إظهار العناصر مع أو بدون تعليقات)." + }, + "withComment": "مع تعليق", + "@withComment": { + "description": "خيار تصفية لإظهار العناصر التي تحتوي على تعليق فقط." + }, + "withoutComment": "بدون تعليق", + "@withoutComment": { + "description": "خيار تصفية لإظهار العناصر التي لا تحتوي على تعليق فقط." + }, + "reportResolved": "تم حل البلاغ.", + "@reportResolved": {}, + "commentApproved": "تمت الموافقة على التعليق.", + "@commentApproved": { + "description": "رسالة عند الموافقة على تعليق" + }, + "commentRejected": "تم رفض التعليق.", + "@commentRejected": { + "description": "رسالة عند رفض تعليق" + }, + "copyHeadlineId": "نسخ معرّف العنوان", + "@copyHeadlineId": { + "description": "نص عنصر القائمة لنسخ معرّف العنوان الرئيسي." + }, + "copyReportedItemId": "نسخ معرّف العنصر المُبلغ عنه", + "@copyReportedItemId": { + "description": "نص عنصر القائمة لنسخ معرّف العنصر المُبلغ عنه." + }, + "viewFeedbackDetails": "عرض تفاصيل التقييم", + "@viewFeedbackDetails": { + "description": "Tooltip for the button to view feedback details." + }, + "reportDetails": "تفاصيل البلاغ", + "@reportDetails": { + "description": "عنوان مربع الحوار الذي يعرض تفاصيل بلاغ مقدم من المستخدم." + }, + "commentDetails": "تفاصيل التعليق", + "@commentDetails": { + "description": "عنوان مربع الحوار الذي يعرض تفاصيل تعليق مقدم من المستخدم." + }, + "communityManagement": "إدارة المجتمع", + "@communityManagement": { + "description": "تسمية لعنوان صفحة إدارة المجتمع والتنقل." + }, + "navContent": "المحتوى", + "@navContent": { + "description": "تسمية تنقل قصيرة لإدارة المحتوى." + }, + "navUsers": "المستخدمون", + "@navUsers": { + "description": "تسمية تنقل قصيرة لإدارة المستخدمين." + }, + "navCommunity": "المجتمع", + "@navCommunity": { + "description": "تسمية تنقل قصيرة لإدارة المجتمع." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 006df5d9..8c046670 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1654,11 +1654,11 @@ "@searchByHeadlineTitle": { "description": "Hint text for the headline search field." }, - "searchByTopicName": "Search by topic name...", + "searchByTopicName": "Search by Name or ID...", "@searchByTopicName": { "description": "Hint text for the topic search field." }, - "searchBySourceName": "Search by source name...", + "searchBySourceName": "Search by Name or ID...", "@searchBySourceName": { "description": "Hint text for the source search field." }, @@ -1762,7 +1762,7 @@ "@filterUsers": { "description": "Title for the filter dialog when filtering users." }, - "searchByUserEmail": "Search by user email...", + "searchByUserEmail": "Search by Email or ID...", "@searchByUserEmail": { "description": "Hint text for the user search field." }, @@ -2245,5 +2245,434 @@ "enableCommunityFeaturesDescription": "Globally activates or deactivates all community-related functionality, including engagement and reporting.", "@enableCommunityFeaturesDescription": { "description": "Description for the master switch to enable all community features." + }, + "communityManagementPageDescription": "Manage user-generated content including engagements (reactions and comments), content reports, and app reviews.", + "@communityManagementPageDescription": { + "description": "Description for the Community Management page" + }, + "engagements": "Engagements", + "@engagements": { + "description": "Label for the engagements subpage" + }, + "reports": "Reports", + "@reports": { + "description": "Label for the reports subpage" + }, + "appReviews": "App Reviews", + "@appReviews": { + "description": "Label for the app reviews subpage" + }, + "user": "User", + "@user": { + "description": "Column header for user" + }, + "engagedContent": "Engaged Content", + "@engagedContent": { + "description": "Column header for engaged content" + }, + "reaction": "Reaction", + "@reaction": { + "description": "Column header for reaction" + }, + "comment": "Comment", + "@comment": { + "description": "Column header for comment" + }, + "commentStatus": "Comment Status", + "@commentStatus": { + "description": "Column header for comment status" + }, + "date": "Date", + "@date": { + "description": "Column header for date" + }, + "approveComment": "Approve Comment", + "@approveComment": { + "description": "Action to approve a comment" + }, + "rejectComment": "Reject Comment", + "@rejectComment": { + "description": "Action to reject a comment" + }, + "viewEngagedContent": "View Content", + "@viewEngagedContent": { + "description": "Action to view the engaged content" + }, + "copyUserId": "Copy User ID", + "@copyUserId": { + "description": "Action to copy the user ID" + }, + "reporter": "Reporter", + "@reporter": { + "description": "Column header for reporter" + }, + "reportedItem": "Reported Item", + "@reportedItem": { + "description": "Column header for reported item" + }, + "reason": "Reason", + "@reason": { + "description": "Column header for reason" + }, + "reportStatus": "Report Status", + "@reportStatus": { + "description": "Column header for report status" + }, + "viewReportedItem": "View Item", + "@viewReportedItem": { + "description": "Action to view the reported item" + }, + "markAsInReview": "Mark as In Review", + "@markAsInReview": { + "description": "Action to mark a report as in review" + }, + "resolveReport": "Resolve Report", + "@resolveReport": { + "description": "Action to resolve a report" + }, + "initialFeedback": "Initial Feedback", + "@initialFeedback": { + "description": "Column header for initial feedback" + }, + "osPromptRequested": "OS Prompt?", + "@osPromptRequested": { + "description": "Column header for OS prompt requested" + }, + "feedbackHistory": "Feedback History", + "@feedbackHistory": { + "description": "Column header for feedback history" + }, + "lastInteraction": "Last Interaction", + "@lastInteraction": { + "description": "Column header for last interaction" + }, + "viewFeedbackHistory": "View History", + "@viewFeedbackHistory": { + "description": "Action to view feedback history" + }, + "reactionTypeLike": "Like", + "@reactionTypeLike": { + "description": "Reaction type: Like" + }, + "reactionTypeInsightful": "Insightful", + "@reactionTypeInsightful": { + "description": "Reaction type: Insightful" + }, + "reactionTypeAmusing": "Amusing", + "@reactionTypeAmusing": { + "description": "Reaction type: Amusing" + }, + "reactionTypeSad": "Sad", + "@reactionTypeSad": { + "description": "Reaction type: Sad" + }, + "reactionTypeAngry": "Angry", + "@reactionTypeAngry": { + "description": "Reaction type: Angry" + }, + "reactionTypeSkeptical": "Skeptical", + "@reactionTypeSkeptical": { + "description": "Reaction type: Skeptical" + }, + "initialAppReviewFeedbackPositive": "Positive", + "@initialAppReviewFeedbackPositive": { + "description": "Initial app review feedback: Positive" + }, + "initialAppReviewFeedbackNegative": "Negative", + "@initialAppReviewFeedbackNegative": { + "description": "Initial app review feedback: Negative" + }, + "filterCommunity": "Filter Community Content", + "@filterCommunity": { + "description": "Action to filter community content" + }, + "searchByEngagementUser": "Search by user email...", + "@searchByEngagementUser": { + "description": "Hint text for searching by engagement user" + }, + "searchByReportReporter": "Search by reporter email...", + "@searchByReportReporter": { + "description": "Hint text for searching by report reporter" + }, + "searchByAppReviewUser": "Search by user email...", + "@searchByAppReviewUser": { + "description": "Hint text for searching by app review user" + }, + "selectCommentStatus": "Select Comment Status", + "@selectCommentStatus": { + "description": "Action to select comment status" + }, + "selectReportStatus": "Select Report Status", + "@selectReportStatus": { + "description": "Action to select report status" + }, + "selectInitialFeedback": "Select Initial Feedback", + "@selectInitialFeedback": { + "description": "Action to select initial feedback" + }, + "selectReportableEntity": "Select Reported Item Type", + "@selectReportableEntity": { + "description": "Action to select reportable entity" + }, + "reportableEntityHeadline": "Headline", + "@reportableEntityHeadline": { + "description": "Reportable entity: Headline" + }, + "reportableEntitySource": "Source", + "@reportableEntitySource": { + "description": "Reportable entity: Source" + }, + "reportableEntityComment": "Comment", + "@reportableEntityComment": { + "description": "Reportable entity: Comment" + }, + "noEngagementsFound": "No engagements found.", + "@noEngagementsFound": { + "description": "Message when no engagements are found" + }, + "noReportsFound": "No reports found.", + "@noReportsFound": { + "description": "Message when no reports are found" + }, + "noAppReviewsFound": "No app reviews found.", + "@noAppReviewsFound": { + "description": "Message when no app reviews are found" + }, + "loadingEngagements": "Loading Engagements", + "@loadingEngagements": { + "description": "Message when engagements are loading" + }, + "loadingReports": "Loading Reports", + "@loadingReports": { + "description": "Message when reports are loading" + }, + "loadingAppReviews": "Loading App Reviews", + "@loadingAppReviews": { + "description": "Message when app reviews are loading" + }, + "userIdCopied": "User ID copied to clipboard.", + "@userIdCopied": { + "description": "Message when user ID is copied" + }, + "reportStatusUpdated": "Report status updated.", + "@reportStatusUpdated": { + "description": "Message when a report status is updated" + }, + "feedbackHistoryForUser": "Feedback History for {email}", + "@feedbackHistoryForUser": { + "description": "Message displaying feedback history for a user", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "noFeedbackHistory": "No feedback history available for this user.", + "@noFeedbackHistory": { + "description": "Message when no feedback history is available" + }, + "feedbackProvidedAt": "Feedback provided at: {date}", + "@feedbackProvidedAt": { + "description": "Message displaying the date feedback was provided", + "placeholders": { + "date": { + "type": "String" + } + } + }, + "feedbackReason": "Reason: {reason}", + "@feedbackReason": { + "description": "Message displaying the reason for feedback", + "placeholders": { + "reason": { + "type": "String" + } + } + }, + "noReasonProvided": "No reason provided.", + "@noReasonProvided": { + "description": "Message when no reason for feedback is provided" + }, + "yes": "Yes", + "@yes": { + "description": "A generic 'Yes' response." + }, + "no": "No", + "@no": { + "description": "A generic 'No' response." + }, + "reportReasonMisinformationOrFakeNews": "Misinformation / Fake News", + "@reportReasonMisinformationOrFakeNews": { + "description": "Report reason: The content is misinformation or fake news." + }, + "reportReasonClickbaitTitle": "Clickbait Title", + "@reportReasonClickbaitTitle": { + "description": "Report reason: The headline is clickbait." + }, + "reportReasonOffensiveOrHateSpeech": "Offensive / Hate Speech", + "@reportReasonOffensiveOrHateSpeech": { + "description": "Report reason: The content is offensive or hate speech." + }, + "reportReasonSpamOrScam": "Spam / Scam", + "@reportReasonSpamOrScam": { + "description": "Report reason: The content is spam or a scam." + }, + "reportReasonBrokenLink": "Broken Link", + "@reportReasonBrokenLink": { + "description": "Report reason: The link in the content is broken." + }, + "reportReasonPaywalled": "Paywalled", + "@reportReasonPaywalled": { + "description": "Report reason: The content is behind a paywall." + }, + "reportReasonLowQualityJournalism": "Low Quality Journalism", + "@reportReasonLowQualityJournalism": { + "description": "Report reason: The source exhibits low-quality journalism." + }, + "reportReasonHighAdDensity": "High Ad Density", + "@reportReasonHighAdDensity": { + "description": "Report reason: The source has a high density of ads." + }, + "reportReasonBlog": "Blog", + "@reportReasonBlog": { + "description": "Report reason: The source is a blog." + }, + "reportReasonGovernmentSource": "Government Source", + "@reportReasonGovernmentSource": { + "description": "Report reason: The source is a government entity." + }, + "reportReasonAggregator": "Aggregator", + "@reportReasonAggregator": { + "description": "Report reason: The source is a news aggregator." + }, + "reportReasonOther": "Other", + "@reportReasonOther": { + "description": "Report reason: Other, not specified." + }, + "reportReasonFrequentPaywalls": "Frequent Paywalls", + "@reportReasonFrequentPaywalls": { + "description": "Report reason: The source frequently uses paywalls." + }, + "reportReasonImpersonation": "Impersonation", + "@reportReasonImpersonation": { + "description": "Report reason: The source is impersonating another entity." + }, + "noNegativeFeedbackHistory": "No negative feedback history found for this user.", + "@noNegativeFeedbackHistory": { + "description": "Message displayed in the feedback history dialog when there is no history." + }, + "reject": "Reject", + "@reject": { + "description": "Confirmation button text for a reject action." + }, + "commentStatusFlaggedByAi": "Flagged by AI", + "@commentStatusFlaggedByAi": { + "description": "Admin-centric status for a comment automatically flagged by AI." + }, + "cancel": "Cancel", + "@cancel": { + "description": "A generic 'Cancel' button text." + }, + "rejectCommentConfirmation": "Are you sure you want to reject and permanently delete this comment? This action cannot be undone.", + "@rejectCommentConfirmation": { + "description": "A simplified confirmation message shown to an admin before they reject and delete a user's comment." + }, + "searchByUserId": "Search by User ID...", + "@searchByUserId": { + "description": "Hint text for the search input field in filter dialogs, specifying to search by User ID." + }, + "viewReportedHeadline": "View Headline", + "@viewReportedHeadline": { + "description": "Action to view the reported headline" + }, + "viewReportedSource": "View Source", + "@viewReportedSource": { + "description": "Action to view the reported source" + }, + "viewReportedComment": "View Comment", + "@viewReportedComment": { + "description": "Action to view the reported comment" + }, + "entityType": "Entity Type", + "@entityType": { + "description": "Column header for the type of entity being reported" + }, + "feedback": "Feedback", + "@feedback": { + "description": "Column header for user feedback on app reviews." + }, + "feedbackDetails": "Feedback Details", + "@feedbackDetails": { + "description": "Title for the dialog showing detailed user feedback." + }, + "moderationStatusPendingReview": "Pending Review", + "@moderationStatusPendingReview": { + "description": "Moderation status: The item is awaiting review." + }, + "moderationStatusResolved": "Resolved", + "@moderationStatusResolved": { + "description": "Moderation status: A decision has been made on the item." + }, + "hasComment": "Has Comment", + "@hasComment": { + "description": "Label for a filter option to show items that have a comment." + }, + "any": "Any", + "@any": { + "description": "Filter option to show items regardless of a certain property (e.g., show items with or without comments)." + }, + "withComment": "With Comment", + "@withComment": { + "description": "Filter option to show only items that have a comment." + }, + "withoutComment": "Without Comment", + "@withoutComment": { + "description": "Filter option to show only items that do not have a comment." + }, + "reportResolved": "Report resolved.", + "@reportResolved": {}, + "commentApproved": "Comment approved.", + "@commentApproved": { + "description": "Message when a comment is approved" + }, + "commentRejected": "Comment rejected.", + "@commentRejected": { + "description": "Message when a comment is rejected" + }, + "copyHeadlineId": "Copy Headline ID", + "@copyHeadlineId": { + "description": "Menu item text to copy the ID of a headline." + }, + "copyReportedItemId": "Copy Reported Item ID", + "@copyReportedItemId": { + "description": "Menu item text to copy the ID of a reported item." + }, + "viewFeedbackDetails": "View Feedback Details", + "@viewFeedbackDetails": { + "description": "Tooltip for the button to view feedback details." + }, + "reportDetails": "Report Details", + "@reportDetails": { + "description": "Title for the dialog showing the details of a user-submitted report." + }, + "commentDetails": "Comment Details", + "@commentDetails": { + "description": "Title for the dialog showing the details of a user-submitted comment." + }, + "communityManagement": "Community Management", + "@communityManagement": { + "description": "Label for the Community Management page title and navigation." + }, + "navContent": "Content", + "@navContent": { + "description": "Short navigation label for Content Management." + }, + "navUsers": "Users", + "@navUsers": { + "description": "Short navigation label for User Management." + }, + "navCommunity": "Community", + "@navCommunity": { + "description": "Short navigation label for Community Management." } } \ No newline at end of file diff --git a/lib/router/route_permissions.dart b/lib/router/route_permissions.dart index a9048eba..a3e2fbec 100644 --- a/lib/router/route_permissions.dart +++ b/lib/router/route_permissions.dart @@ -11,6 +11,7 @@ final Map> routePermissions = { Routes.overviewName, Routes.contentManagementName, Routes.userManagementName, + Routes.communityManagementName, Routes.appConfigurationName, }, // Publishers have a more restricted access, focused on content creation diff --git a/lib/router/router.dart b/lib/router/router.dart index 49bb22dc..89d622ac 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -12,6 +12,8 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/b import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/view/authentication_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/view/email_code_verification_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/view/request_code_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/view/community_management_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/community_management/widgets/community_filter_dialog/community_filter_dialog.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; @@ -98,6 +100,7 @@ GoRouter createRouter({ Routes.overviewName: Routes.overview, Routes.contentManagementName: Routes.contentManagement, Routes.userManagementName: Routes.userManagement, + Routes.communityManagementName: Routes.communityManagement, Routes.appConfigurationName: Routes.appConfiguration, }; @@ -374,6 +377,27 @@ GoRouter createRouter({ ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.communityManagement, + name: Routes.communityManagementName, + builder: (context, state) => const CommunityManagementPage(), + routes: [ + GoRoute( + path: Routes.communityFilterDialog, + name: Routes.communityFilterDialogName, + pageBuilder: (context, state) { + return const MaterialPage( + fullscreenDialog: true, + child: CommunityFilterDialog(), + ); + }, + ), + ], + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( diff --git a/lib/router/routes.dart b/lib/router/routes.dart index b3666253..679308b3 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -115,4 +115,16 @@ abstract final class Routes { /// The name for the user filter dialog route. static const String userFilterDialogName = 'userFilterDialog'; + + /// The path for the community management section. + static const String communityManagement = '/community-management'; + + /// The name for the community management section route. + static const String communityManagementName = 'communityManagement'; + + /// The name for the community filter dialog route. + static const String communityFilterDialog = 'community-filter-dialog'; + + /// The name for the community filter dialog route. + static const String communityFilterDialogName = 'communityFilterDialog'; } diff --git a/lib/shared/extensions/app_review_feedback_extension.dart b/lib/shared/extensions/app_review_feedback_extension.dart new file mode 100644 index 00000000..4f82613b --- /dev/null +++ b/lib/shared/extensions/app_review_feedback_extension.dart @@ -0,0 +1,17 @@ +import 'package:core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +extension InitialAppReviewFeedbackX on AppReviewFeedback { + /// Returns a localized, admin-centric string for the + /// [AppReviewFeedback]. + String l10n(BuildContext context) { + final l10n = context.l10n; + switch (this) { + case AppReviewFeedback.positive: + return l10n.initialAppReviewFeedbackPositive; + case AppReviewFeedback.negative: + return l10n.initialAppReviewFeedbackNegative; + } + } +} diff --git a/lib/shared/extensions/extensions.dart b/lib/shared/extensions/extensions.dart index a5956785..f7c37cb8 100644 --- a/lib/shared/extensions/extensions.dart +++ b/lib/shared/extensions/extensions.dart @@ -1,11 +1,17 @@ export 'ad_platform_type_l10n.dart'; export 'ad_type_l10n.dart'; +export 'app_review_feedback_extension.dart'; export 'app_user_role_l10n.dart'; export 'banner_ad_shape_l10n.dart'; export 'content_status_l10n.dart'; export 'dashboard_user_role_l10n.dart'; +export 'engagement_mode_l10n.dart'; export 'feed_decorator_type_l10n.dart'; +export 'feed_item_click_behavior_l10n.dart'; +export 'moderation_status_l10n.dart'; export 'push_notification_provider_l10n.dart'; export 'push_notification_subscription_delivery_type_l10n.dart'; +export 'report_reason_extension.dart'; +export 'reportable_entity_extension.dart'; export 'source_type_l10n.dart'; export 'string_truncate.dart'; diff --git a/lib/shared/extensions/moderation_status_l10n.dart b/lib/shared/extensions/moderation_status_l10n.dart new file mode 100644 index 00000000..ed13d103 --- /dev/null +++ b/lib/shared/extensions/moderation_status_l10n.dart @@ -0,0 +1,17 @@ +import 'package:core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// Provides a localized string representation for [ModerationStatus]. +extension ModerationStatusL10n on ModerationStatus { + /// Returns the localized string for the moderation status. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case ModerationStatus.pendingReview: + return l10n.moderationStatusPendingReview; + case ModerationStatus.resolved: + return l10n.moderationStatusResolved; + } + } +} diff --git a/lib/shared/extensions/report_reason_extension.dart b/lib/shared/extensions/report_reason_extension.dart new file mode 100644 index 00000000..3849de92 --- /dev/null +++ b/lib/shared/extensions/report_reason_extension.dart @@ -0,0 +1,50 @@ +import 'package:core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +extension ReportReasonX on String { + /// Returns a localized, admin-centric string for a report reason. + /// + /// This extension maps the raw string value of various report reason enums + /// (e.g., [HeadlineReportReason], [SourceReportReason], [CommentReportReason]) + /// to a user-friendly, localized string. + String l10n(BuildContext context) { + final l10n = context.l10n; + switch (this) { + // HeadlineReportReason + case 'misinformationOrFakeNews': + return l10n.reportReasonMisinformationOrFakeNews; + case 'clickbaitTitle': + return l10n.reportReasonClickbaitTitle; + case 'offensiveOrHateSpeech': + return l10n.reportReasonOffensiveOrHateSpeech; + case 'spamOrScam': + return l10n.reportReasonSpamOrScam; + case 'brokenLink': + return l10n.reportReasonBrokenLink; + case 'paywalled': + return l10n.reportReasonPaywalled; + + // SourceReportReason + case 'lowQualityJournalism': + return l10n.reportReasonLowQualityJournalism; + case 'highAdDensity': + return l10n.reportReasonHighAdDensity; + case 'blog': + return l10n.reportReasonBlog; + case 'governmentSource': + return l10n.reportReasonGovernmentSource; + case 'aggregator': + return l10n.reportReasonAggregator; + case 'other': + return l10n.reportReasonOther; + case 'frequentPaywalls': + return l10n.reportReasonFrequentPaywalls; + case 'impersonation': + return l10n.reportReasonImpersonation; + + default: + return this; // Fallback to raw string if no localization found + } + } +} diff --git a/lib/shared/extensions/reportable_entity_extension.dart b/lib/shared/extensions/reportable_entity_extension.dart new file mode 100644 index 00000000..8cccce57 --- /dev/null +++ b/lib/shared/extensions/reportable_entity_extension.dart @@ -0,0 +1,18 @@ +import 'package:core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +extension ReportableEntityX on ReportableEntity { + /// Returns a localized, admin-centric string for the [ReportableEntity]. + String l10n(BuildContext context) { + final l10n = context.l10n; + switch (this) { + case ReportableEntity.headline: + return l10n.reportableEntityHeadline; + case ReportableEntity.source: + return l10n.reportableEntitySource; + case ReportableEntity.engagement: + return l10n.reportableEntityComment; + } + } +} diff --git a/lib/shared/services/pending_updates_service.dart b/lib/shared/services/pending_updates_service.dart new file mode 100644 index 00000000..70f403ef --- /dev/null +++ b/lib/shared/services/pending_updates_service.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:data_repository/data_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +/// Represents the status of a pending update. +enum UpdateStatus { + /// The update has been confirmed and executed. + confirmed, + + /// The update has been successfully undone. + undone, +} + +/// {@template update_event} +/// An event representing a change in the status of a pending update. +/// +/// Contains the ID of the item and its new status. +/// {@endtemplate} +@immutable +class UpdateEvent extends Equatable { + /// {@macro update_event} + const UpdateEvent(this.id, this.status, {this.originalItem}); + + /// The unique identifier of the item. + final String id; + + /// The new status of the update. + final UpdateStatus status; + + /// The original item before the update was requested. + /// This is provided when an update is undone. + final T? originalItem; + + @override + List get props => [id, status, originalItem]; +} + +/// {@template pending_updates_service} +/// An abstract interface for a service that manages pending updates. +/// +/// This service provides a mechanism to request a delayed update of an item, +/// allowing for an "undo" period. +/// {@endtemplate} +abstract class PendingUpdatesService { + /// A stream that emits [UpdateEvent]s when an update is confirmed or undone. + Stream> get updateEvents; + + /// Requests the update of an item of a specific type [T]. + void requestUpdate({ + required T originalItem, + required T updatedItem, + required DataRepository repository, + required Duration undoDuration, + }); + + /// Cancels a pending update for the item with the given [id]. + void undoUpdate(String id); + + /// Disposes of the service's resources. + void dispose(); +} + +/// {@template pending_updates_service_impl} +/// A concrete implementation of [PendingUpdatesService]. +/// {@endtemplate} +class PendingUpdatesServiceImpl implements PendingUpdatesService { + /// {@macro pending_updates_service_impl} + PendingUpdatesServiceImpl({Logger? logger}) + : _logger = logger ?? Logger('PendingUpdatesServiceImpl'); + + final Logger _logger; + final _updateEventController = + StreamController>.broadcast(); + final Map> _pendingUpdateTimers = {}; + + @override + Stream> get updateEvents => + _updateEventController.stream; + + @override + void requestUpdate({ + required T originalItem, + required T updatedItem, + required DataRepository repository, + required Duration undoDuration, + }) { + final id = (updatedItem as dynamic).id as String; + _logger.info('Requesting update for item ID: $id'); + + if (_pendingUpdateTimers.containsKey(id)) { + _logger.info('Cancelling existing pending update for ID: $id'); + _pendingUpdateTimers.remove(id)?.timer.cancel(); + } + + final timer = Timer(undoDuration, () async { + try { + await repository.update(id: id, item: updatedItem); + _logger.info('Update confirmed for item ID: $id'); + _updateEventController.add( + UpdateEvent(id, UpdateStatus.confirmed), + ); + } catch (error) { + _logger.severe('Error confirming update for item ID: $id: $error'); + _updateEventController.addError(error); + } finally { + _pendingUpdateTimers.remove(id); + } + }); + + _pendingUpdateTimers[id] = _PendingUpdate( + timer: timer, + originalItem: originalItem, + ); + } + + @override + void undoUpdate(String id) { + _logger.info('Attempting to undo update for item ID: $id'); + final pendingUpdate = _pendingUpdateTimers.remove(id); + if (pendingUpdate != null) { + pendingUpdate.timer.cancel(); + _logger.info('Update undone for item ID: $id'); + _updateEventController.add( + UpdateEvent( + id, + UpdateStatus.undone, + originalItem: pendingUpdate.originalItem, + ), + ); + } else { + _logger.warning('No pending update found for ID: $id to undo.'); + } + } + + @override + void dispose() { + _logger.info( + 'Disposing PendingUpdatesService. Cancelling ${_pendingUpdateTimers.length} pending timers.', + ); + for (final pendingUpdate in _pendingUpdateTimers.values) { + pendingUpdate.timer.cancel(); + } + _pendingUpdateTimers.clear(); + _updateEventController.close(); + } +} + +@immutable +class _PendingUpdate extends Equatable { + const _PendingUpdate({required this.timer, required this.originalItem}); + + final Timer timer; + final T originalItem; + + @override + List get props => [timer, originalItem]; +} diff --git a/lib/user_management/bloc/user_filter/user_filter_bloc.dart b/lib/user_management/bloc/user_filter/user_filter_bloc.dart index 686e4e87..6f1e1ddd 100644 --- a/lib/user_management/bloc/user_filter/user_filter_bloc.dart +++ b/lib/user_management/bloc/user_filter/user_filter_bloc.dart @@ -69,4 +69,30 @@ class UserFilterBloc extends Bloc { ), ); } + + /// Builds the filter map for the data repository query. + Map buildFilterMap() { + final filter = {}; + + if (state.searchQuery.isNotEmpty) { + filter[r'$or'] = [ + { + 'email': {r'$regex': state.searchQuery, r'$options': 'i'}, + }, + {'_id': state.searchQuery}, + ]; + } + + if (state.selectedAppRoles.isNotEmpty) { + filter['appRole'] = { + r'$in': state.selectedAppRoles.map((r) => r.name).toList(), + }; + } + if (state.selectedDashboardRoles.isNotEmpty) { + filter['dashboardRole'] = { + r'$in': state.selectedDashboardRoles.map((r) => r.name).toList(), + }; + } + return filter; + } } diff --git a/pubspec.lock b/pubspec.lock index 16c1b196..2a1ead4d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,8 +89,8 @@ packages: dependency: "direct main" description: path: "." - ref: a960fe8f340fc8b74b651997de45aee10d8435aa - resolved-ref: a960fe8f340fc8b74b651997de45aee10d8435aa + ref: "8bae6eb17369b76f72961870a22ee13d3073fa61" + resolved-ref: "8bae6eb17369b76f72961870a22ee13d3073fa61" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index c86f6508..8b52ceac 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,7 +94,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: a960fe8f340fc8b74b651997de45aee10d8435aa + ref: 8bae6eb17369b76f72961870a22ee13d3073fa61 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git