diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7613024d..427cd04c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3997,6 +3997,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Community'** String get navCommunity; + + /// Title for the dialog confirming user promotion. + /// + /// In en, this message translates to: + /// **'Confirm Promotion'** + String get confirmPromotionTitle; + + /// Message for the dialog confirming user promotion. + /// + /// In en, this message translates to: + /// **'Are you sure you want to promote {email} to a Publisher?'** + String confirmPromotionMessage(String email); + + /// Title for the dialog confirming user demotion. + /// + /// In en, this message translates to: + /// **'Confirm Demotion'** + String get confirmDemotionTitle; + + /// Message for the dialog confirming user demotion. + /// + /// In en, this message translates to: + /// **'Are you sure you want to demote {email} to a standard user?'** + String confirmDemotionMessage(String email); + + /// Tooltip for the icon indicating a premium user. + /// + /// In en, this message translates to: + /// **'Premium'** + String get premiumUserTooltip; + + /// Tooltip for the icon indicating an admin user. + /// + /// In en, this message translates to: + /// **'Admin'** + String get adminUserTooltip; + + /// Tooltip for the icon indicating a publisher user. + /// + /// In en, this message translates to: + /// **'Publisher'** + String get publisherUserTooltip; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 7d47caca..a5514382 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -2142,4 +2142,29 @@ class AppLocalizationsAr extends AppLocalizations { @override String get navCommunity => 'المجتمع'; + + @override + String get confirmPromotionTitle => 'تأكيد الترقية'; + + @override + String confirmPromotionMessage(String email) { + return 'هل أنت متأكد أنك تريد ترقية $email إلى ناشر؟'; + } + + @override + String get confirmDemotionTitle => 'تأكيد التخفيض'; + + @override + String confirmDemotionMessage(String email) { + return 'هل أنت متأكد أنك تريد تخفيض رتبة $email إلى مستخدم عادي؟'; + } + + @override + String get premiumUserTooltip => 'مستخدم مميز'; + + @override + String get adminUserTooltip => 'مسؤول'; + + @override + String get publisherUserTooltip => 'ناشر'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0c5af7b8..ab2c3add 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2148,4 +2148,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get navCommunity => 'Community'; + + @override + String get confirmPromotionTitle => 'Confirm Promotion'; + + @override + String confirmPromotionMessage(String email) { + return 'Are you sure you want to promote $email to a Publisher?'; + } + + @override + String get confirmDemotionTitle => 'Confirm Demotion'; + + @override + String confirmDemotionMessage(String email) { + return 'Are you sure you want to demote $email to a standard user?'; + } + + @override + String get premiumUserTooltip => 'Premium'; + + @override + String get adminUserTooltip => 'Admin'; + + @override + String get publisherUserTooltip => 'Publisher'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index f6dfd1d9..cd58a70c 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -2698,5 +2698,43 @@ "navCommunity": "المجتمع", "@navCommunity": { "description": "تسمية تنقل قصيرة لإدارة المجتمع." + }, + "confirmPromotionTitle": "تأكيد الترقية", + "@confirmPromotionTitle": { + "description": "عنوان مربع حوار تأكيد ترقية المستخدم." + }, + "confirmPromotionMessage": "هل أنت متأكد أنك تريد ترقية {email} إلى ناشر؟", + "@confirmPromotionMessage": { + "description": "رسالة مربع حوار تأكيد ترقية المستخدم.", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "confirmDemotionTitle": "تأكيد التخفيض", + "@confirmDemotionTitle": { + "description": "عنوان مربع حوار تأكيد تخفيض رتبة المستخدم." + }, + "confirmDemotionMessage": "هل أنت متأكد أنك تريد تخفيض رتبة {email} إلى مستخدم عادي؟", + "@confirmDemotionMessage": { + "description": "رسالة مربع حوار تأكيد تخفيض رتبة المستخدم.", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "premiumUserTooltip": "مستخدم مميز", + "@premiumUserTooltip": { + "description": "تلميح للأيقونة التي تشير إلى مستخدم مميز." + }, + "adminUserTooltip": "مسؤول", + "@adminUserTooltip": { + "description": "تلميح للأيقونة التي تشير إلى مستخدم مسؤول." + }, + "publisherUserTooltip": "ناشر", + "@publisherUserTooltip": { + "description": "تلميح للأيقونة التي تشير إلى مستخدم ناشر." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e0dce8e8..028171e5 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2694,5 +2694,43 @@ "navCommunity": "Community", "@navCommunity": { "description": "Short navigation label for Community Management." + }, + "confirmPromotionTitle": "Confirm Promotion", + "@confirmPromotionTitle": { + "description": "Title for the dialog confirming user promotion." + }, + "confirmPromotionMessage": "Are you sure you want to promote {email} to a Publisher?", + "@confirmPromotionMessage": { + "description": "Message for the dialog confirming user promotion.", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "confirmDemotionTitle": "Confirm Demotion", + "@confirmDemotionTitle": { + "description": "Title for the dialog confirming user demotion." + }, + "confirmDemotionMessage": "Are you sure you want to demote {email} to a standard user?", + "@confirmDemotionMessage": { + "description": "Message for the dialog confirming user demotion.", + "placeholders": { + "email": { + "type": "String" + } + } + }, + "premiumUserTooltip": "Premium", + "@premiumUserTooltip": { + "description": "Tooltip for the icon indicating a premium user." + }, + "adminUserTooltip": "Admin", + "@adminUserTooltip": { + "description": "Tooltip for the icon indicating an admin user." + }, + "publisherUserTooltip": "Publisher", + "@publisherUserTooltip": { + "description": "Tooltip for the icon indicating a publisher user." } } \ No newline at end of file diff --git a/lib/shared/extensions/app_user_role_ui.dart b/lib/shared/extensions/app_user_role_ui.dart new file mode 100644 index 00000000..9a76a81f --- /dev/null +++ b/lib/shared/extensions/app_user_role_ui.dart @@ -0,0 +1,23 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; + +/// An extension on [AppUserRole] to provide UI-related helpers. +extension AppUserRoleUI on AppUserRole { + /// A convenience getter to check if the user role is premium. + bool get isPremium => this == AppUserRole.premiumUser; + + /// Returns a premium indicator icon wrapped in a tooltip if the user is a + /// premium user. + /// + /// Returns a gold star icon for premium users, otherwise returns null. + Widget? getPremiumIcon(AppLocalizations l10n) { + if (isPremium) { + return Tooltip( + message: l10n.premiumUserTooltip, + child: const Icon(Icons.star, color: Colors.amber, size: 16), + ); + } + return null; + } +} diff --git a/lib/shared/widgets/confirmation_dialog.dart b/lib/shared/widgets/confirmation_dialog.dart new file mode 100644 index 00000000..6c4047c6 --- /dev/null +++ b/lib/shared/widgets/confirmation_dialog.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// {@template confirmation_dialog} +/// A reusable dialog to confirm a user action. +/// +/// This dialog displays a title, content, and two buttons: a cancel button +/// and a confirm button. The text for these buttons and the action to be +/// performed on confirmation are customizable. +/// {@endtemplate} +class ConfirmationDialog extends StatelessWidget { + /// {@macro confirmation_dialog} + const ConfirmationDialog({ + required this.title, + required this.content, + required this.onConfirm, + this.confirmText, + super.key, + }); + + /// The title of the dialog. + final String title; + + /// The main content or question of the dialog. + final String content; + + /// The callback to be executed when the user confirms the action. + final VoidCallback onConfirm; + + /// The text for the confirmation button. Defaults to 'Confirm'. + final String? confirmText; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + return AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancelButton), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + onConfirm(); + }, + child: Text(confirmText ?? l10n.confirmSaveButton), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/selection_page/selection_page.dart b/lib/shared/widgets/selection_page/selection_page.dart new file mode 100644 index 00000000..8b8deea6 --- /dev/null +++ b/lib/shared/widgets/selection_page/selection_page.dart @@ -0,0 +1,2 @@ +export 'searchable_selection_page.dart'; +export 'selection_page_arguments.dart'; diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 311cf333..e3b9f66d 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -1,2 +1,4 @@ -export 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/about_icon.dart'; -export 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/searchable_selection_input.dart'; +export 'about_icon.dart'; +export 'confirmation_dialog.dart'; +export 'searchable_selection_input.dart'; +export 'selection_page/selection_page.dart'; 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 6f1e1ddd..9a67f19e 100644 --- a/lib/user_management/bloc/user_filter/user_filter_bloc.dart +++ b/lib/user_management/bloc/user_filter/user_filter_bloc.dart @@ -3,6 +3,8 @@ import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart' show UserManagementBloc; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/authentication_filter.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/subscription_filter.dart'; part 'user_filter_event.dart'; part 'user_filter_state.dart'; @@ -18,8 +20,6 @@ class UserFilterBloc extends Bloc { /// {@macro user_filter_bloc} UserFilterBloc() : super(const UserFilterState()) { on(_onSearchQueryChanged); - on(_onAppRolesChanged); - on(_onDashboardRolesChanged); on(_onFilterReset); on(_onFilterApplied); } @@ -32,22 +32,6 @@ class UserFilterBloc extends Bloc { emit(state.copyWith(searchQuery: event.query)); } - /// Handles changes to the selected app roles filter. - void _onAppRolesChanged( - UserFilterAppRolesChanged event, - Emitter emit, - ) { - emit(state.copyWith(selectedAppRoles: event.appRoles)); - } - - /// Handles changes to the selected dashboard roles filter. - void _onDashboardRolesChanged( - UserFilterDashboardRolesChanged event, - Emitter emit, - ) { - emit(state.copyWith(selectedDashboardRoles: event.dashboardRoles)); - } - /// Resets all filters to their default values. void _onFilterReset( UserFilterReset event, @@ -64,35 +48,10 @@ class UserFilterBloc extends Bloc { emit( state.copyWith( searchQuery: event.searchQuery, - selectedAppRoles: event.selectedAppRoles, - selectedDashboardRoles: event.selectedDashboardRoles, + authenticationFilter: event.authenticationFilter, + subscriptionFilter: event.subscriptionFilter, + dashboardRole: event.dashboardRole, ), ); } - - /// 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/lib/user_management/bloc/user_filter/user_filter_event.dart b/lib/user_management/bloc/user_filter/user_filter_event.dart index fa8e3123..7c0aeade 100644 --- a/lib/user_management/bloc/user_filter/user_filter_event.dart +++ b/lib/user_management/bloc/user_filter/user_filter_event.dart @@ -18,26 +18,6 @@ final class UserFilterSearchQueryChanged extends UserFilterEvent { List get props => [query]; } -/// Event to update the selected app roles for filtering users. -final class UserFilterAppRolesChanged extends UserFilterEvent { - const UserFilterAppRolesChanged(this.appRoles); - - final List appRoles; - - @override - List get props => [appRoles]; -} - -/// Event to update the selected dashboard roles for filtering users. -final class UserFilterDashboardRolesChanged extends UserFilterEvent { - const UserFilterDashboardRolesChanged(this.dashboardRoles); - - final List dashboardRoles; - - @override - List get props => [dashboardRoles]; -} - /// Event to reset all filters to their default state. final class UserFilterReset extends UserFilterEvent { const UserFilterReset(); @@ -48,18 +28,21 @@ final class UserFilterReset extends UserFilterEvent { final class UserFilterApplied extends UserFilterEvent { const UserFilterApplied({ required this.searchQuery, - required this.selectedAppRoles, - required this.selectedDashboardRoles, + required this.authenticationFilter, + required this.subscriptionFilter, + this.dashboardRole, }); final String searchQuery; - final List selectedAppRoles; - final List selectedDashboardRoles; + final AuthenticationFilter authenticationFilter; + final SubscriptionFilter subscriptionFilter; + final DashboardUserRole? dashboardRole; @override List get props => [ searchQuery, - selectedAppRoles, - selectedDashboardRoles, + authenticationFilter, + subscriptionFilter, + dashboardRole ?? '', ]; } diff --git a/lib/user_management/bloc/user_filter/user_filter_state.dart b/lib/user_management/bloc/user_filter/user_filter_state.dart index 6381b3de..794cac0a 100644 --- a/lib/user_management/bloc/user_filter/user_filter_state.dart +++ b/lib/user_management/bloc/user_filter/user_filter_state.dart @@ -10,37 +10,43 @@ class UserFilterState extends Equatable { /// {@macro user_filter_state} const UserFilterState({ this.searchQuery = '', - this.selectedAppRoles = const [], - this.selectedDashboardRoles = const [], + this.authenticationFilter = AuthenticationFilter.all, + this.subscriptionFilter = SubscriptionFilter.all, + this.dashboardRole, }); /// The current search query for filtering users by email. final String searchQuery; - /// The list of selected app roles to filter users by. - final List selectedAppRoles; + /// The selected authentication status filter. + final AuthenticationFilter authenticationFilter; - /// The list of selected dashboard roles to filter users by. - final List selectedDashboardRoles; + /// The selected subscription status filter. + final SubscriptionFilter subscriptionFilter; + + /// The selected dashboard role filter. + final DashboardUserRole? dashboardRole; /// Creates a copy of this [UserFilterState] with updated values. UserFilterState copyWith({ String? searchQuery, - List? selectedAppRoles, - List? selectedDashboardRoles, + AuthenticationFilter? authenticationFilter, + SubscriptionFilter? subscriptionFilter, + DashboardUserRole? dashboardRole, }) { return UserFilterState( searchQuery: searchQuery ?? this.searchQuery, - selectedAppRoles: selectedAppRoles ?? this.selectedAppRoles, - selectedDashboardRoles: - selectedDashboardRoles ?? this.selectedDashboardRoles, + authenticationFilter: authenticationFilter ?? this.authenticationFilter, + subscriptionFilter: subscriptionFilter ?? this.subscriptionFilter, + dashboardRole: dashboardRole ?? this.dashboardRole, ); } @override List get props => [ searchQuery, - selectedAppRoles, - selectedDashboardRoles, + authenticationFilter, + subscriptionFilter, + dashboardRole ?? '', ]; } diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart index 9a50b77d..212a4f0d 100644 --- a/lib/user_management/bloc/user_management_bloc.dart +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -6,6 +6,8 @@ import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.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/user_management/bloc/user_filter/user_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/authentication_filter.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/subscription_filter.dart'; import 'package:logging/logging.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -77,20 +79,58 @@ class UserManagementBloc Map buildUsersFilterMap(UserFilterState state) { final filter = {}; + final orConditions = >[]; + if (state.searchQuery.isNotEmpty) { - filter['email'] = {r'$regex': state.searchQuery, r'$options': 'i'}; + orConditions + ..add({ + 'email': {r'$regex': state.searchQuery, r'$options': 'i'}, + }) + ..add({'_id': state.searchQuery}); + } + + if (orConditions.isNotEmpty) { + filter[r'$or'] = orConditions; + } + + final authRoles = {}; + + switch (state.authenticationFilter) { + case AuthenticationFilter.authenticated: + authRoles.addAll([ + AppUserRole.standardUser.name, + AppUserRole.premiumUser.name, + ]); + case AuthenticationFilter.anonymous: + authRoles.add(AppUserRole.guestUser.name); + case AuthenticationFilter.all: + break; + } + + final subscriptionRoles = {}; + switch (state.subscriptionFilter) { + case SubscriptionFilter.premium: + subscriptionRoles.add(AppUserRole.premiumUser.name); + case SubscriptionFilter.free: + subscriptionRoles.addAll([ + AppUserRole.guestUser.name, + AppUserRole.standardUser.name, + ]); + case SubscriptionFilter.all: + break; } - if (state.selectedAppRoles.isNotEmpty) { - filter['appRole'] = { - r'$in': state.selectedAppRoles.map((r) => r.name).toList(), - }; + final intersectingRoles = + authRoles.isNotEmpty && subscriptionRoles.isNotEmpty + ? authRoles.intersection(subscriptionRoles) + : (authRoles.isNotEmpty ? authRoles : subscriptionRoles); + + if (intersectingRoles.isNotEmpty) { + filter['appRole'] = {r'$in': intersectingRoles.toList()}; } - if (state.selectedDashboardRoles.isNotEmpty) { - filter['dashboardRole'] = { - r'$in': state.selectedDashboardRoles.map((r) => r.name).toList(), - }; + if (state.dashboardRole != null) { + filter['dashboardRole'] = state.dashboardRole!.name; } return filter; diff --git a/lib/user_management/enums/authentication_filter.dart b/lib/user_management/enums/authentication_filter.dart new file mode 100644 index 00000000..57b8c891 --- /dev/null +++ b/lib/user_management/enums/authentication_filter.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// Defines the filter options for a user's authentication status. +enum AuthenticationFilter { + /// Show all users regardless of authentication status. + all, + + /// Show only authenticated users. + authenticated, + + /// Show only anonymous users. + anonymous, +} + +/// An extension to get the localized string for [AuthenticationFilter]. +extension AuthenticationFilterL10n on AuthenticationFilter { + /// Returns the localized string for the authentication filter option. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case AuthenticationFilter.all: + return l10n.any; + case AuthenticationFilter.authenticated: + return l10n.authenticationAuthenticated; + case AuthenticationFilter.anonymous: + return l10n.authenticationAnonymous; + } + } +} diff --git a/lib/user_management/enums/subscription_filter.dart b/lib/user_management/enums/subscription_filter.dart new file mode 100644 index 00000000..410836cc --- /dev/null +++ b/lib/user_management/enums/subscription_filter.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// Defines the filter options for a user's subscription status. +enum SubscriptionFilter { + /// Show all users regardless of subscription status. + all, + + /// Show only users with a free subscription. + free, + + /// Show only users with a premium subscription. + premium, +} + +/// An extension to get the localized string for [SubscriptionFilter]. +extension SubscriptionFilterL10n on SubscriptionFilter { + /// Returns the localized string for the subscription filter option. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case SubscriptionFilter.all: + return l10n.any; + case SubscriptionFilter.free: + return l10n.subscriptionFree; + case SubscriptionFilter.premium: + return l10n.subscriptionPremium; + } + } +} diff --git a/lib/user_management/view/dashboard_user_role_ui.dart b/lib/user_management/view/dashboard_user_role_ui.dart new file mode 100644 index 00000000..a5fb1e69 --- /dev/null +++ b/lib/user_management/view/dashboard_user_role_ui.dart @@ -0,0 +1,38 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; + +/// An extension on [DashboardUserRole] to provide UI-related helpers. +extension DashboardUserRoleUI on DashboardUserRole { + /// A convenience getter to check if the user has a privileged dashboard role. + bool get isPrivileged { + return this == DashboardUserRole.admin || + this == DashboardUserRole.publisher; + } + + /// Returns a role indicator icon wrapped in a tooltip. + Widget? getRoleIcon(AppLocalizations l10n) { + switch (this) { + case DashboardUserRole.admin: + return Tooltip( + message: l10n.adminUserTooltip, + child: const Icon( + Icons.admin_panel_settings, + color: Colors.blueAccent, + size: 16, + ), + ); + case DashboardUserRole.publisher: + return Tooltip( + message: l10n.publisherUserTooltip, + child: const Icon( + Icons.publish, + color: Colors.green, + size: 16, + ), + ); + case DashboardUserRole.none: + return null; + } + } +} diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index b3490c3f..892eb816 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -4,8 +4,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_user_role_ui.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'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/authentication_filter.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/subscription_filter.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/view/dashboard_user_role_ui.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_action_buttons.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -41,7 +45,9 @@ class _UsersPageState extends State { /// Checks if any filters are currently active in the UserFilterBloc. bool _areFiltersActive(UserFilterState state) { - return state.searchQuery.isNotEmpty || state.selectedAppRoles.isNotEmpty; + return state.searchQuery.isNotEmpty || + state.authenticationFilter != AuthenticationFilter.all || + state.subscriptionFilter != SubscriptionFilter.all; } @override @@ -136,13 +142,9 @@ class _UsersPageState extends State { ), if (!isMobile) DataColumn2( - label: Text(l10n.subscription), + label: Text(l10n.createdAt), size: ColumnSize.S, ), - DataColumn2( - label: Text(l10n.createdAt), - size: ColumnSize.S, - ), DataColumn2( label: Text(l10n.actions), size: ColumnSize.S, @@ -226,20 +228,22 @@ class _UsersDataSource extends DataTableSource { DataCell( Row( children: [ - Expanded( - child: Text( - user.email, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), + Flexible( + child: Text(user.email, overflow: TextOverflow.ellipsis), ), + if (user.appRole.getPremiumIcon(l10n) case final icon?) ...[ + const SizedBox(width: AppSpacing.sm), + icon, + ], + if (user.dashboardRole.getRoleIcon(l10n) case final icon?) ...[ + const SizedBox(width: AppSpacing.xs), + icon, + ], ], ), ), if (!isMobile) DataCell(Text(user.appRole.authenticationStatusL10n(context))), - if (!isMobile) - DataCell(Text(user.appRole.subscriptionStatusL10n(context))), DataCell( Text( DateFormat('dd-MM-yyyy').format(user.createdAt.toLocal()), @@ -280,19 +284,3 @@ extension AuthenticationStatusL10n on AppUserRole { } } } - -/// An extension to get the localized string for the subscription status -/// derived from [AppUserRole]. -extension SubscriptionStatusL10n on AppUserRole { - /// Returns the localized subscription status string. - String subscriptionStatusL10n(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - switch (this) { - case AppUserRole.guestUser: - case AppUserRole.standardUser: - return l10n.subscriptionFree; - case AppUserRole.premiumUser: - return l10n.subscriptionPremium; - } - } -} diff --git a/lib/user_management/widgets/user_action_buttons.dart b/lib/user_management/widgets/user_action_buttons.dart index 7ea77570..0d1d0ead 100644 --- a/lib/user_management/widgets/user_action_buttons.dart +++ b/lib/user_management/widgets/user_action_buttons.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart' show ReadContext; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.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_management_bloc.dart'; /// {@template user_action_buttons} @@ -97,21 +98,43 @@ class UserActionButtons extends StatelessWidget { ); } - void _onPromote(BuildContext context) => - context.read().add( - UserDashboardRoleChanged( - userId: user.id, - dashboardRole: DashboardUserRole.publisher, - ), - ); + void _onPromote(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => ConfirmationDialog( + title: l10n.confirmPromotionTitle, + content: l10n.confirmPromotionMessage(user.email), + confirmText: l10n.promoteToPublisher, + onConfirm: () { + context.read().add( + UserDashboardRoleChanged( + userId: user.id, + dashboardRole: DashboardUserRole.publisher, + ), + ); + }, + ), + ); + } - void _onDemote(BuildContext context) => - context.read().add( - UserDashboardRoleChanged( - userId: user.id, - dashboardRole: DashboardUserRole.none, - ), - ); + void _onDemote(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) => ConfirmationDialog( + title: l10n.confirmDemotionTitle, + content: l10n.confirmDemotionMessage(user.email), + confirmText: l10n.demoteToUser, + onConfirm: () { + context.read().add( + UserDashboardRoleChanged( + userId: user.id, + dashboardRole: DashboardUserRole.none, + ), + ); + }, + ), + ); + } void _onCopyId(BuildContext context) { Clipboard.setData(ClipboardData(text: user.id)); diff --git a/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart index e04fe798..fa318111 100644 --- a/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart +++ b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart @@ -2,6 +2,8 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/authentication_filter.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/subscription_filter.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_filter_dialog/user_filter_dialog.dart' show UserFilterDialog; @@ -22,9 +24,10 @@ class UserFilterDialogBloc UserFilterDialogBloc() : super(const UserFilterDialogState()) { on(_onFilterDialogInitialized); on(_onSearchQueryChanged); - on(_onAppRolesChanged); - on(_onDashboardRolesChanged); on(_onFilterDialogReset); + on(_onAuthenticationChanged); + on(_onSubscriptionChanged); + on(_onDashboardRoleChanged); } /// Initializes the dialog's state from the main [UserFilterBloc]'s state. @@ -35,8 +38,9 @@ class UserFilterDialogBloc emit( state.copyWith( searchQuery: event.userFilterState.searchQuery, - selectedAppRoles: event.userFilterState.selectedAppRoles, - selectedDashboardRoles: event.userFilterState.selectedDashboardRoles, + authenticationFilter: event.userFilterState.authenticationFilter, + subscriptionFilter: event.userFilterState.subscriptionFilter, + dashboardRole: event.userFilterState.dashboardRole, ), ); } @@ -49,20 +53,27 @@ class UserFilterDialogBloc emit(state.copyWith(searchQuery: event.query)); } - /// Updates the temporary selected app roles. - void _onAppRolesChanged( - UserFilterDialogAppRolesChanged event, + void _onAuthenticationChanged( + UserFilterDialogAuthenticationChanged event, Emitter emit, ) { - emit(state.copyWith(selectedAppRoles: event.appRoles)); + emit(state.copyWith(authenticationFilter: event.authenticationFilter)); } - /// Updates the temporary selected dashboard roles. - void _onDashboardRolesChanged( - UserFilterDialogDashboardRolesChanged event, + void _onSubscriptionChanged( + UserFilterDialogSubscriptionChanged event, Emitter emit, ) { - emit(state.copyWith(selectedDashboardRoles: event.dashboardRoles)); + emit(state.copyWith(subscriptionFilter: event.subscriptionFilter)); + } + + void _onDashboardRoleChanged( + UserFilterDialogDashboardRoleChanged event, + Emitter emit, + ) { + // Directly set the state to the selected role. The UI's `onSelected` + // will pass `null` when 'Any' is tapped. + emit(state.copyWith(dashboardRole: event.dashboardRole)); } /// Resets all temporary filter selections in the dialog. diff --git a/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_event.dart b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_event.dart index eed34339..1e0da355 100644 --- a/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_event.dart +++ b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_event.dart @@ -30,25 +30,35 @@ final class UserFilterDialogSearchQueryChanged extends UserFilterDialogEvent { List get props => [query]; } -/// Event to update the temporary selected app roles in the dialog. -final class UserFilterDialogAppRolesChanged extends UserFilterDialogEvent { - const UserFilterDialogAppRolesChanged(this.appRoles); +/// Event to update the temporary authentication filter in the dialog. +final class UserFilterDialogAuthenticationChanged + extends UserFilterDialogEvent { + const UserFilterDialogAuthenticationChanged(this.authenticationFilter); - final List appRoles; + final AuthenticationFilter authenticationFilter; @override - List get props => [appRoles]; + List get props => [authenticationFilter]; } -/// Event to update the temporary selected dashboard roles in the dialog. -final class UserFilterDialogDashboardRolesChanged - extends UserFilterDialogEvent { - const UserFilterDialogDashboardRolesChanged(this.dashboardRoles); +/// Event to update the temporary subscription filter in the dialog. +final class UserFilterDialogSubscriptionChanged extends UserFilterDialogEvent { + const UserFilterDialogSubscriptionChanged(this.subscriptionFilter); + + final SubscriptionFilter subscriptionFilter; + + @override + List get props => [subscriptionFilter]; +} + +/// Event to update the temporary dashboard role filter in the dialog. +final class UserFilterDialogDashboardRoleChanged extends UserFilterDialogEvent { + const UserFilterDialogDashboardRoleChanged(this.dashboardRole); - final List dashboardRoles; + final DashboardUserRole? dashboardRole; @override - List get props => [dashboardRoles]; + List get props => [dashboardRole]; } /// Event to reset all temporary filter selections in the dialog. diff --git a/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_state.dart b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_state.dart index 7bc8edce..cd258bd0 100644 --- a/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_state.dart +++ b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_state.dart @@ -10,37 +10,43 @@ final class UserFilterDialogState extends Equatable { /// {@macro user_filter_dialog_state} const UserFilterDialogState({ this.searchQuery = '', - this.selectedAppRoles = const [], - this.selectedDashboardRoles = const [], + this.authenticationFilter = AuthenticationFilter.all, + this.subscriptionFilter = SubscriptionFilter.all, + this.dashboardRole, }); /// The current text in the search query field. final String searchQuery; - /// The list of app roles to be included in the filter. - final List selectedAppRoles; + /// The selected authentication status filter. + final AuthenticationFilter authenticationFilter; - /// The list of dashboard roles to be included in the filter. - final List selectedDashboardRoles; + /// The selected subscription status filter. + final SubscriptionFilter subscriptionFilter; + + /// The selected dashboard role filter. + final DashboardUserRole? dashboardRole; /// Creates a copy of this [UserFilterDialogState] with updated values. UserFilterDialogState copyWith({ String? searchQuery, - List? selectedAppRoles, - List? selectedDashboardRoles, + AuthenticationFilter? authenticationFilter, + SubscriptionFilter? subscriptionFilter, + DashboardUserRole? dashboardRole, }) { return UserFilterDialogState( searchQuery: searchQuery ?? this.searchQuery, - selectedAppRoles: selectedAppRoles ?? this.selectedAppRoles, - selectedDashboardRoles: - selectedDashboardRoles ?? this.selectedDashboardRoles, + authenticationFilter: authenticationFilter ?? this.authenticationFilter, + subscriptionFilter: subscriptionFilter ?? this.subscriptionFilter, + dashboardRole: dashboardRole ?? this.dashboardRole, ); } @override List get props => [ searchQuery, - selectedAppRoles, - selectedDashboardRoles, + authenticationFilter, + subscriptionFilter, + dashboardRole, ]; } diff --git a/lib/user_management/widgets/user_filter_dialog/user_filter_dialog.dart b/lib/user_management/widgets/user_filter_dialog/user_filter_dialog.dart index cb203187..1345b409 100644 --- a/lib/user_management/widgets/user_filter_dialog/user_filter_dialog.dart +++ b/lib/user_management/widgets/user_filter_dialog/user_filter_dialog.dart @@ -2,10 +2,10 @@ 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/l10n/l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/app_user_role_l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/dashboard_user_role_l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/searchable_selection_input.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/enums/authentication_filter.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/enums/subscription_filter.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -45,8 +45,9 @@ class _UserFilterDialogState extends State { context.read().add( UserFilterApplied( searchQuery: filterDialogState.searchQuery, - selectedAppRoles: filterDialogState.selectedAppRoles, - selectedDashboardRoles: filterDialogState.selectedDashboardRoles, + authenticationFilter: filterDialogState.authenticationFilter, + subscriptionFilter: filterDialogState.subscriptionFilter, + dashboardRole: filterDialogState.dashboardRole, ), ); } @@ -116,40 +117,52 @@ class _UserFilterDialogState extends State { ), const SizedBox(height: AppSpacing.lg), - // Filter for AppUserRole. - SearchableSelectionInput( - label: l10n.appRole, - hintText: l10n.selectAppRoles, - isMultiSelect: true, - selectedItems: filterDialogState.selectedAppRoles, - itemBuilder: (context, item) => Text(item.l10n(context)), - itemToString: (item) => item.l10n(context), - onChanged: (items) { - context.read().add( - UserFilterDialogAppRolesChanged(items ?? []), - ); - }, - staticItems: AppUserRole.values, + // Authentication Filter + _FilterSection( + title: l10n.authentication, + selectedValue: filterDialogState.authenticationFilter, + values: AuthenticationFilter.values, + onSelected: (value) => + context.read().add( + UserFilterDialogAuthenticationChanged( + value!, + ), + ), + chipLabelBuilder: (value) => value.l10n(context), ), const SizedBox(height: AppSpacing.lg), - // Filter for DashboardUserRole. - SearchableSelectionInput( - label: l10n.dashboardRole, - hintText: l10n.selectDashboardRoles, - isMultiSelect: true, - selectedItems: filterDialogState.selectedDashboardRoles, - itemBuilder: (context, item) => Text(item.l10n(context)), - itemToString: (item) => item.l10n(context), - onChanged: (items) { - context.read().add( - UserFilterDialogDashboardRolesChanged(items ?? []), - ); - }, - // Exclude 'admin' from the selectable roles in the filter. - staticItems: DashboardUserRole.values - .where((role) => role != DashboardUserRole.admin) - .toList(), + // Subscription Filter + _FilterSection( + title: l10n.subscription, + selectedValue: filterDialogState.subscriptionFilter, + values: SubscriptionFilter.values, + onSelected: (value) => + context.read().add( + UserFilterDialogSubscriptionChanged( + value!, + ), + ), + chipLabelBuilder: (value) => value.l10n(context), + ), + const SizedBox(height: AppSpacing.lg), + + // Dashboard Role Filter + _FilterSection( + title: l10n.dashboardRole, + selectedValue: filterDialogState.dashboardRole, + values: const [ + DashboardUserRole.admin, + DashboardUserRole.publisher, + ], + onSelected: (value) => + context.read().add( + UserFilterDialogDashboardRoleChanged( + value, + ), + ), + chipLabelBuilder: (value) => value.l10n(context), + includeAllOption: true, ), ], ), @@ -160,3 +173,56 @@ class _UserFilterDialogState extends State { ); } } + +class _FilterSection extends StatelessWidget { + const _FilterSection({ + required this.title, + required this.selectedValue, + required this.values, + required this.onSelected, + required this.chipLabelBuilder, + this.includeAllOption = false, + }); + + final String title; + final T? selectedValue; + final List values; + final ValueChanged onSelected; + final String Function(T) chipLabelBuilder; + final bool includeAllOption; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final textTheme = Theme.of(context).textTheme; + + final allValues = includeAllOption + ? [null, ...values.where((v) => v != null)] + : values; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: allValues.map((value) { + final isSelected = selectedValue == value; + final label = value == null + ? l10n.any + : chipLabelBuilder(value as T); + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) { + onSelected(value); + }, + ); + }).toList(), + ), + ], + ); + } +}