From e261ef0f7715fc0221359e55167e10781d250126 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:01:00 +0100 Subject: [PATCH 01/43] feat(routing): add routes for user management feature Adds the necessary route paths and names for the new user management page and its associated filter dialog. --- lib/router/routes.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 4971d9b1..319562e4 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -166,4 +166,16 @@ abstract final class Routes { /// The name for the edit local video ad page route. static const String editLocalVideoAdName = 'editLocalVideoAd'; + + /// The path for the user management section. + static const String userManagement = '/user-management'; + + /// The name for the user management section route. + static const String userManagementName = 'userManagement'; + + /// The path for the user filter dialog. + static const String userFilterDialog = 'user-filter-dialog'; + + /// The name for the user filter dialog route. + static const String userFilterDialogName = 'userFilterDialog'; } From 18c4b8aef10bcb4978d25a826f3464d82d23ca80 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:06:25 +0100 Subject: [PATCH 02/43] chore: boilerplate --- lib/user_management/bloc/bloc/user_filter_bloc.dart | 13 +++++++++++++ .../bloc/bloc/user_filter_event.dart | 8 ++++++++ .../bloc/bloc/user_filter_state.dart | 10 ++++++++++ lib/user_management/bloc/user_management_bloc.dart | 13 +++++++++++++ lib/user_management/bloc/user_management_event.dart | 8 ++++++++ lib/user_management/bloc/user_management_state.dart | 10 ++++++++++ lib/user_management/view/user_management_page.dart | 0 .../bloc/user_filter_dialog_bloc.dart | 13 +++++++++++++ .../bloc/user_filter_dialog_event.dart | 8 ++++++++ .../bloc/user_filter_dialog_state.dart | 10 ++++++++++ 10 files changed, 93 insertions(+) create mode 100644 lib/user_management/bloc/bloc/user_filter_bloc.dart create mode 100644 lib/user_management/bloc/bloc/user_filter_event.dart create mode 100644 lib/user_management/bloc/bloc/user_filter_state.dart create mode 100644 lib/user_management/bloc/user_management_bloc.dart create mode 100644 lib/user_management/bloc/user_management_event.dart create mode 100644 lib/user_management/bloc/user_management_state.dart create mode 100644 lib/user_management/view/user_management_page.dart create mode 100644 lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart create mode 100644 lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_event.dart create mode 100644 lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_state.dart diff --git a/lib/user_management/bloc/bloc/user_filter_bloc.dart b/lib/user_management/bloc/bloc/user_filter_bloc.dart new file mode 100644 index 00000000..cd2f27bf --- /dev/null +++ b/lib/user_management/bloc/bloc/user_filter_bloc.dart @@ -0,0 +1,13 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'user_filter_event.dart'; +part 'user_filter_state.dart'; + +class UserFilterBloc extends Bloc { + UserFilterBloc() : super(UserFilterInitial()) { + on((event, emit) { + // TODO: implement event handler + }); + } +} diff --git a/lib/user_management/bloc/bloc/user_filter_event.dart b/lib/user_management/bloc/bloc/user_filter_event.dart new file mode 100644 index 00000000..688be8d8 --- /dev/null +++ b/lib/user_management/bloc/bloc/user_filter_event.dart @@ -0,0 +1,8 @@ +part of 'user_filter_bloc.dart'; + +sealed class UserFilterEvent extends Equatable { + const UserFilterEvent(); + + @override + List get props => []; +} diff --git a/lib/user_management/bloc/bloc/user_filter_state.dart b/lib/user_management/bloc/bloc/user_filter_state.dart new file mode 100644 index 00000000..6c8311de --- /dev/null +++ b/lib/user_management/bloc/bloc/user_filter_state.dart @@ -0,0 +1,10 @@ +part of 'user_filter_bloc.dart'; + +sealed class UserFilterState extends Equatable { + const UserFilterState(); + + @override + List get props => []; +} + +final class UserFilterInitial extends UserFilterState {} diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart new file mode 100644 index 00000000..fcd34ba0 --- /dev/null +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -0,0 +1,13 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'user_management_event.dart'; +part 'user_management_state.dart'; + +class UserManagementBloc extends Bloc { + UserManagementBloc() : super(UserManagementInitial()) { + on((event, emit) { + // TODO: implement event handler + }); + } +} diff --git a/lib/user_management/bloc/user_management_event.dart b/lib/user_management/bloc/user_management_event.dart new file mode 100644 index 00000000..56b12a8a --- /dev/null +++ b/lib/user_management/bloc/user_management_event.dart @@ -0,0 +1,8 @@ +part of 'user_management_bloc.dart'; + +sealed class UserManagementEvent extends Equatable { + const UserManagementEvent(); + + @override + List get props => []; +} diff --git a/lib/user_management/bloc/user_management_state.dart b/lib/user_management/bloc/user_management_state.dart new file mode 100644 index 00000000..a16a6fb9 --- /dev/null +++ b/lib/user_management/bloc/user_management_state.dart @@ -0,0 +1,10 @@ +part of 'user_management_bloc.dart'; + +sealed class UserManagementState extends Equatable { + const UserManagementState(); + + @override + List get props => []; +} + +final class UserManagementInitial extends UserManagementState {} diff --git a/lib/user_management/view/user_management_page.dart b/lib/user_management/view/user_management_page.dart new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..7977af7a --- /dev/null +++ b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_bloc.dart @@ -0,0 +1,13 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'user_filter_dialog_event.dart'; +part 'user_filter_dialog_state.dart'; + +class UserFilterDialogBloc extends Bloc { + UserFilterDialogBloc() : super(UserFilterDialogInitial()) { + on((event, emit) { + // TODO: implement event handler + }); + } +} 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 new file mode 100644 index 00000000..3ee68498 --- /dev/null +++ b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_event.dart @@ -0,0 +1,8 @@ +part of 'user_filter_dialog_bloc.dart'; + +sealed class UserFilterDialogEvent extends Equatable { + const UserFilterDialogEvent(); + + @override + List get props => []; +} 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 new file mode 100644 index 00000000..db6aa293 --- /dev/null +++ b/lib/user_management/widgets/user_filter_dialog/bloc/user_filter_dialog_state.dart @@ -0,0 +1,10 @@ +part of 'user_filter_dialog_bloc.dart'; + +sealed class UserFilterDialogState extends Equatable { + const UserFilterDialogState(); + + @override + List get props => []; +} + +final class UserFilterDialogInitial extends UserFilterDialogState {} From 6a5f63746ec814c6934aa938ebbbfc65a5db0525 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:06:30 +0100 Subject: [PATCH 03/43] feat(user_management): instantiate user data repository Adds the `DataClient` and `DataRepository` to the application's dependency injection setup in `bootstrap.dart`. This makes the user repository available throughout the application, enabling the new user management feature to fetch and manage user data. --- lib/bootstrap.dart | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index cefb30ab..59430988 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -65,6 +65,7 @@ Future bootstrap( DataClient countriesClient; DataClient languagesClient; DataClient localAdsClient; + DataClient usersClient; if (appConfig.environment == app_config.AppEnvironment.demo) { headlinesClient = DataInMemory( @@ -125,6 +126,12 @@ Future bootstrap( initialData: localAdsFixturesData, logger: Logger('DataInMemory'), ); + usersClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + // No initial data for users in demo mode. + logger: Logger('DataInMemory'), + ); } else if (appConfig.environment == app_config.AppEnvironment.development) { headlinesClient = DataApi( httpClient: httpClient!, @@ -196,6 +203,13 @@ Future bootstrap( toJson: LocalAd.toJson, logger: Logger('DataApi'), ); + usersClient = DataApi( + httpClient: httpClient, + modelName: 'user', + fromJson: User.fromJson, + toJson: (user) => user.toJson(), + logger: Logger('DataApi'), + ); } else { headlinesClient = DataApi( httpClient: httpClient!, @@ -267,6 +281,13 @@ Future bootstrap( toJson: FeedItem.toJson, logger: Logger('DataApi'), ); + usersClient = DataApi( + httpClient: httpClient, + modelName: 'user', + fromJson: User.fromJson, + toJson: (user) => user.toJson(), + logger: Logger('DataApi'), + ); } pendingDeletionsService = PendingDeletionsServiceImpl( @@ -300,6 +321,7 @@ Future bootstrap( final localAdsRepository = DataRepository( dataClient: localAdsClient, ); + final usersRepository = DataRepository(dataClient: usersClient); return App( authenticationRepository: authenticationRepository, @@ -313,6 +335,7 @@ Future bootstrap( countriesRepository: countriesRepository, languagesRepository: languagesRepository, localAdsRepository: localAdsRepository, + usersRepository: usersRepository, storageService: kvStorage, environment: environment, pendingDeletionsService: pendingDeletionsService, From fe622bd5b78fd772710ed80d89fe47ea56e6857e Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:08:31 +0100 Subject: [PATCH 04/43] feat(user_management): create user filter state Adds the `UserFilterState` class, which will hold the state for filtering the user list, including search query and selected roles. --- .../bloc/bloc/user_filter_state.dart | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/user_management/bloc/bloc/user_filter_state.dart b/lib/user_management/bloc/bloc/user_filter_state.dart index 6c8311de..6381b3de 100644 --- a/lib/user_management/bloc/bloc/user_filter_state.dart +++ b/lib/user_management/bloc/bloc/user_filter_state.dart @@ -1,10 +1,46 @@ part of 'user_filter_bloc.dart'; -sealed class UserFilterState extends Equatable { - const UserFilterState(); - +/// {@template user_filter_state} +/// The state for the user filter feature. +/// +/// This state holds the current filter criteria for the user list, including +/// search query and selected roles. +/// {@endtemplate} +class UserFilterState extends Equatable { + /// {@macro user_filter_state} + const UserFilterState({ + this.searchQuery = '', + this.selectedAppRoles = const [], + this.selectedDashboardRoles = const [], + }); + + /// 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 list of selected dashboard roles to filter users by. + final List selectedDashboardRoles; + + /// Creates a copy of this [UserFilterState] with updated values. + UserFilterState copyWith({ + String? searchQuery, + List? selectedAppRoles, + List? selectedDashboardRoles, + }) { + return UserFilterState( + searchQuery: searchQuery ?? this.searchQuery, + selectedAppRoles: selectedAppRoles ?? this.selectedAppRoles, + selectedDashboardRoles: + selectedDashboardRoles ?? this.selectedDashboardRoles, + ); + } + @override - List get props => []; + List get props => [ + searchQuery, + selectedAppRoles, + selectedDashboardRoles, + ]; } - -final class UserFilterInitial extends UserFilterState {} From 21f0a3c1f1dcf64f4004e6b58799aa7e4f68933c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:10:17 +0100 Subject: [PATCH 05/43] feat(user_management): create user filter events Adds the `UserFilterEvent` sealed class and its concrete implementations. These events will be used to update the filter state for the user list, including changing the search query, updating selected roles, and resetting or applying filters. --- .../bloc/bloc/user_filter_event.dart | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/lib/user_management/bloc/bloc/user_filter_event.dart b/lib/user_management/bloc/bloc/user_filter_event.dart index 688be8d8..45eed6a9 100644 --- a/lib/user_management/bloc/bloc/user_filter_event.dart +++ b/lib/user_management/bloc/bloc/user_filter_event.dart @@ -1,8 +1,65 @@ part of 'user_filter_bloc.dart'; +/// Base class for all events related to the [UserFilterBloc]. sealed class UserFilterEvent extends Equatable { const UserFilterEvent(); @override List get props => []; } + +/// Event to update the search query for filtering users. +final class UserFilterSearchQueryChanged extends UserFilterEvent { + const UserFilterSearchQueryChanged(this.query); + + final String query; + + @override + 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(); +} + +/// Event dispatched from the filter dialog to apply all selected filters at +/// once. +final class UserFilterApplied extends UserFilterEvent { + const UserFilterApplied({ + required this.searchQuery, + required this.selectedAppRoles, + required this.selectedDashboardRoles, + }); + + final String searchQuery; + final List selectedAppRoles; + final List selectedDashboardRoles; + + @override + List get props => [ + searchQuery, + selectedAppRoles, + selectedDashboardRoles, + ]; +} From c3d3d88dbd4506f730da28ee9661358fa71b6a5f Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:12:27 +0100 Subject: [PATCH 06/43] feat(user_management): create user filter bloc Adds the `UserFilterBloc` to manage the state of filters for the user list. This BLoC handles events for updating the search query, selected roles, and resetting filters. --- .../bloc/bloc/user_filter_bloc.dart | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/lib/user_management/bloc/bloc/user_filter_bloc.dart b/lib/user_management/bloc/bloc/user_filter_bloc.dart index cd2f27bf..a9cac08f 100644 --- a/lib/user_management/bloc/bloc/user_filter_bloc.dart +++ b/lib/user_management/bloc/bloc/user_filter_bloc.dart @@ -1,13 +1,68 @@ import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; part 'user_filter_event.dart'; part 'user_filter_state.dart'; +/// {@template user_filter_bloc} +/// A BLoC that manages the state of filters for the user list. +/// +/// This BLoC is responsible for holding the current filter criteria, such as +/// search queries and selected roles, which are then used by the +/// [UserManagementBloc] to fetch the filtered list of users. +/// {@endtemplate} class UserFilterBloc extends Bloc { - UserFilterBloc() : super(UserFilterInitial()) { - on((event, emit) { - // TODO: implement event handler - }); + /// {@macro user_filter_bloc} + UserFilterBloc() : super(const UserFilterState()) { + on(_onSearchQueryChanged); + on(_onAppRolesChanged); + on(_onDashboardRolesChanged); + on(_onFilterReset); + on(_onFilterApplied); + } + + /// Handles changes to the search query filter. + void _onSearchQueryChanged( + UserFilterSearchQueryChanged event, + Emitter emit, + ) { + 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, + Emitter emit, + ) { + emit(const UserFilterState()); + } + + /// Applies a new set of filters, typically from the filter dialog. + void _onFilterApplied( + UserFilterApplied event, + Emitter emit, + ) { + emit(state.copyWith( + searchQuery: event.searchQuery, + selectedAppRoles: event.selectedAppRoles, + selectedDashboardRoles: event.selectedDashboardRoles, + ),); } } From f9731e45fbdd9fac070a1260e160b02af50fd932 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:14:14 +0100 Subject: [PATCH 07/43] feat(user_management): create user management state Adds the `UserManagementState` and `UserManagementStatus` enum. This state will manage the list of users, pagination, loading status, and error handling for the user management feature. --- .../bloc/user_management_state.dart | 76 +++++++++++++++++-- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/user_management/bloc/user_management_state.dart b/lib/user_management/bloc/user_management_state.dart index a16a6fb9..ce68132b 100644 --- a/lib/user_management/bloc/user_management_state.dart +++ b/lib/user_management/bloc/user_management_state.dart @@ -1,10 +1,74 @@ part of 'user_management_bloc.dart'; -sealed class UserManagementState extends Equatable { - const UserManagementState(); - - @override - List get props => []; +/// Represents the status of user management operations. +enum UserManagementStatus { + /// The operation is in its initial state. + initial, + + /// Data is currently being loaded or an operation is in progress. + loading, + + /// Data has been successfully loaded or an operation completed. + success, + + /// An error occurred during data loading or an operation. + failure, } -final class UserManagementInitial extends UserManagementState {} +/// {@template user_management_state} +/// The state for the user management feature. +/// +/// This state holds the list of users, their loading status, pagination +/// details, and any exceptions that may have occurred. +/// {@endtemplate} +class UserManagementState extends Equatable { + /// {@macro user_management_state} + const UserManagementState({ + this.status = UserManagementStatus.initial, + this.users = const [], + this.cursor, + this.hasMore = false, + this.exception, + }); + + /// The status of the users loading operation. + final UserManagementStatus status; + + /// The list of users currently displayed. + final List users; + + /// The cursor for fetching the next page of users. + final String? cursor; + + /// Indicates if there are more users available to load. + final bool hasMore; + + /// The exception encountered during a failed operation, if any. + final HttpException? exception; + + /// Creates a copy of this [UserManagementState] with updated values. + UserManagementState copyWith({ + UserManagementStatus? status, + List? users, + String? cursor, + bool? hasMore, + HttpException? exception, + }) { + return UserManagementState( + status: status ?? this.status, + users: users ?? this.users, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + exception: exception, + ); + } + + @override + List get props => [ + status, + users, + cursor, + hasMore, + exception, + ]; +} From 5eacf7781f62b836a15af17c629c6aa0599eecc8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:15:33 +0100 Subject: [PATCH 08/43] feat(user_management): create user management events Adds the `UserManagementEvent` sealed class and its concrete implementations. These events will be used to load users with pagination and filters, and to change a user's app or dashboard role. --- .../bloc/user_management_event.dart | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/user_management/bloc/user_management_event.dart b/lib/user_management/bloc/user_management_event.dart index 56b12a8a..fa23aaf4 100644 --- a/lib/user_management/bloc/user_management_event.dart +++ b/lib/user_management/bloc/user_management_event.dart @@ -1,8 +1,65 @@ part of 'user_management_bloc.dart'; +/// Base class for all events related to the [UserManagementBloc]. sealed class UserManagementEvent extends Equatable { const UserManagementEvent(); @override - List get props => []; + List get props => []; +} + +/// Event to request loading of users with pagination and filtering. +final class LoadUsersRequested extends UserManagementEvent { + const LoadUsersRequested({ + this.startAfterId, + this.limit, + this.forceRefresh = false, + this.filter, + }); + + /// The ID of the last user from the previous page, used for pagination. + final String? startAfterId; + + /// The maximum number of users to fetch. + final int? limit; + + /// Whether to force a refresh of the user list, ignoring any cached data. + final bool forceRefresh; + + /// An optional filter map to apply to the user query. + final Map? filter; + + @override + List get props => [startAfterId, limit, forceRefresh, filter]; +} + +/// Event to change a user's dashboard role. +final class UserDashboardRoleChanged extends UserManagementEvent { + const UserDashboardRoleChanged({ + required this.userId, + required this.dashboardRole, + }); + + /// The ID of the user to update. + final String userId; + + /// The new dashboard role to assign to the user. + final DashboardUserRole dashboardRole; + + @override + List get props => [userId, dashboardRole]; +} + +/// Event to change a user's app role. +final class UserAppRoleChanged extends UserManagementEvent { + const UserAppRoleChanged({required this.userId, required this.appRole}); + + /// The ID of the user to update. + final String userId; + + /// The new app role to assign to the user. + final AppUserRole appRole; + + @override + List get props => [userId, appRole]; } From c6fefe16340cca485f21eeecf6932df698cf1de2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:17:17 +0100 Subject: [PATCH 09/43] feat(user_management): create user management bloc Adds the `UserManagementBloc` responsible for managing the state of the user list. This BLoC handles: - Fetching users with pagination and filters. - Listening to the `UserFilterBloc` to react to filter changes. - Listening to repository updates for automatic data refresh. - Handling events to change a user's app or dashboard role. --- .../bloc/user_management_bloc.dart | 160 +++++++++++++++++- 1 file changed, 156 insertions(+), 4 deletions(-) diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart index fcd34ba0..42993e4d 100644 --- a/lib/user_management/bloc/user_management_bloc.dart +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -1,13 +1,165 @@ +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/shared/constants/app_constants.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; part 'user_management_event.dart'; part 'user_management_state.dart'; -class UserManagementBloc extends Bloc { - UserManagementBloc() : super(UserManagementInitial()) { - on((event, emit) { - // TODO: implement event handler +/// {@template user_management_bloc} +/// A BLoC that manages the state for the user management feature. +/// +/// This BLoC is responsible for fetching users, handling pagination, +/// applying filters from the [UserFilterBloc], and processing user +/// role updates. +/// {@endtemplate} +class UserManagementBloc + extends Bloc { + /// {@macro user_management_bloc} + UserManagementBloc({ + required DataRepository usersRepository, + required UserFilterBloc userFilterBloc, + }) : _usersRepository = usersRepository, + _userFilterBloc = userFilterBloc, + super(const UserManagementState()) { + on(_onLoadUsersRequested); + on(_onUserDashboardRoleChanged); + on(_onUserAppRoleChanged); + + // Listen for changes in the filter BLoC to trigger a data reload. + _filterSubscription = _userFilterBloc.stream.listen((_) { + add( + LoadUsersRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildUsersFilterMap(_userFilterBloc.state), + ), + ); }); + + // Listen for external updates to the User entity to trigger a refresh. + _userUpdateSubscription = _usersRepository.entityUpdated + .where((type) => type == User) + .listen((_) { + add( + LoadUsersRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildUsersFilterMap(_userFilterBloc.state), + ), + ); + }); + } + + final DataRepository _usersRepository; + final UserFilterBloc _userFilterBloc; + + late final StreamSubscription _filterSubscription; + late final StreamSubscription _userUpdateSubscription; + + @override + Future close() { + _filterSubscription.cancel(); + _userUpdateSubscription.cancel(); + return super.close(); + } + + /// Builds a filter map for users from the given filter state. + Map buildUsersFilterMap(UserFilterState state) { + final filter = {}; + + if (state.searchQuery.isNotEmpty) { + filter['email'] = {r'$regex': state.searchQuery, r'$options': 'i'}; + } + + 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; + } + + /// Handles the request to load a paginated list of users. + Future _onLoadUsersRequested( + LoadUsersRequested event, + Emitter emit, + ) async { + // Avoid re-fetching if data is already loaded and not a pagination or + // forced refresh request. + if (state.status == UserManagementStatus.success && + state.users.isNotEmpty && + event.startAfterId == null && + !event.forceRefresh && + event.filter == null) { + return; + } + + emit(state.copyWith(status: UserManagementStatus.loading)); + try { + final isPaginating = event.startAfterId != null; + final previousUsers = isPaginating ? state.users : []; + + final paginatedUsers = await _usersRepository.readAll( + filter: event.filter ?? buildUsersFilterMap(_userFilterBloc.state), + sort: [const SortOption('createdAt', SortOrder.desc)], + pagination: PaginationOptions( + cursor: event.startAfterId, + limit: event.limit, + ), + ); + emit( + state.copyWith( + status: UserManagementStatus.success, + users: [...previousUsers, ...paginatedUsers.items], + cursor: paginatedUsers.cursor, + hasMore: paginatedUsers.hasMore, + ), + ); + } on HttpException catch (e) { + emit(state.copyWith(status: UserManagementStatus.failure, exception: e)); + } catch (e) { + emit( + state.copyWith( + status: UserManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + /// Handles the request to change a user's dashboard role. + Future _onUserDashboardRoleChanged( + UserDashboardRoleChanged event, + Emitter emit, + ) async { + final userToUpdate = state.users.firstWhere((u) => u.id == event.userId); + await _usersRepository.update( + id: event.userId, + item: userToUpdate.copyWith(dashboardRole: event.dashboardRole), + ); + } + + /// Handles the request to change a user's app role. + Future _onUserAppRoleChanged( + UserAppRoleChanged event, + Emitter emit, + ) async { + final userToUpdate = state.users.firstWhere((u) => u.id == event.userId); + await _usersRepository.update( + id: event.userId, + item: userToUpdate.copyWith(appRole: event.appRole), + ); } } From bd55313c529be470db3f9d5e6774b6dbcd294e33 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:18:23 +0100 Subject: [PATCH 10/43] feat(user_management): create user management page Adds the `UserManagementPage`, which serves as the main scaffold and entry point for the user management feature. This page includes the app bar with a title and a filter button that navigates to the user filter dialog. The body contains the `UsersPage` where the user data will be displayed. --- .../view/user_management_page.dart | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/lib/user_management/view/user_management_page.dart b/lib/user_management/view/user_management_page.dart index e69de29b..1de13d01 100644 --- a/lib/user_management/view/user_management_page.dart +++ b/lib/user_management/view/user_management_page.dart @@ -0,0 +1,66 @@ +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/router/routes.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/about_icon.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/view/users_page.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template user_management_page} +/// A page for User Management, providing a view of all system users. +/// +/// This page serves as the main entry point for the user management feature. +/// It includes an app bar with a title, an information icon, and a filter +/// button to open the user filtering dialog. The body of the page contains +/// the [UsersPage], which displays the user data table. +/// {@endtemplate} +class UserManagementPage extends StatelessWidget { + /// {@macro user_management_page} + const UserManagementPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.userManagement), + const SizedBox(width: AppSpacing.xs), + AboutIcon( + dialogTitle: l10n.userManagement, + dialogDescription: l10n.userManagementPageDescription, + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.filter_list), + tooltip: l10n.filter, + onPressed: () { + // Construct arguments map to pass to the filter dialog route. + // This ensures the dialog is initialized with the current filter + // state. + final arguments = { + 'userFilterState': context.read().state, + }; + + // Push the user filter dialog as a new route. + context.pushNamed( + Routes.userFilterDialogName, + extra: arguments, + ); + }, + ), + ], + ), + // The body of the scaffold is the UsersPage, which contains the + // paginated data table for displaying users. + body: const UsersPage(), + ); + } +} From 1fcf3782591c182a9084566537a34741a08a4c9b Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:20:17 +0100 Subject: [PATCH 11/43] feat(user_management): create users page with data table Adds the `UsersPage` widget, which displays a paginated data table of system users. This page: - Listens to the `UserManagementBloc` to display users. - Handles loading, error, and empty states. - Implements pagination to fetch more users as the user navigates through pages. - Defines the data source and columns for the user table. --- lib/user_management/view/users_page.dart | 243 +++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 lib/user_management/view/users_page.dart diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart new file mode 100644 index 00000000..cf213255 --- /dev/null +++ b/lib/user_management/view/users_page.dart @@ -0,0 +1,243 @@ +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/l10n/l10n.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/bloc/user_management_bloc.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'; + +/// {@template users_page} +/// A page for displaying and managing Users in a tabular format. +/// +/// This widget listens to the [UserManagementBloc] and displays a paginated +/// data table of users. It handles loading, success, failure, and empty states. +/// {@endtemplate} +class UsersPage extends StatefulWidget { + /// {@macro users_page} + const UsersPage({super.key}); + + @override + State createState() => _UsersPageState(); +} + +class _UsersPageState extends State { + @override + void initState() { + super.initState(); + // Initial load of users, applying the default filter from UserFilterBloc. + context.read().add( + LoadUsersRequested( + limit: kDefaultRowsPerPage, + filter: context.read().buildUsersFilterMap( + context.read().state, + ), + ), + ); + } + + /// Checks if any filters are currently active in the UserFilterBloc. + bool _areFiltersActive(UserFilterState state) { + return state.searchQuery.isNotEmpty || + state.selectedAppRoles.isNotEmpty || + state.selectedDashboardRoles.isNotEmpty; + } + + @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 userFilterState = context.watch().state; + final filtersActive = _areFiltersActive(userFilterState); + + // Show loading indicator when fetching for the first time. + if (state.status == UserManagementStatus.loading && + state.users.isEmpty) { + return LoadingStateWidget( + icon: Icons.people_outline, + headline: l10n.loadingUsers, + subheadline: l10n.pleaseWait, + ); + } + + // Show failure widget if an error occurs. + if (state.status == UserManagementStatus.failure) { + return FailureStateWidget( + exception: state.exception!, + onRetry: () => context.read().add( + LoadUsersRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildUsersFilterMap( + context.read().state, + ), + ), + ), + ); + } + + // Handle empty states. + if (state.users.isEmpty) { + if (filtersActive) { + // If filters are active, show a message to reset them. + 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 UserFilterReset(), + ); + }, + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } + // If no filters are active, show a generic "no users" message. + return Center(child: Text(l10n.noUsersFound)); + } + + // Display the data table with users. + return Column( + children: [ + // Show a linear progress indicator during subsequent loads/pagination. + if (state.status == UserManagementStatus.loading && + state.users.isNotEmpty) + const LinearProgressIndicator(), + Expanded( + child: PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.email), + size: ColumnSize.L, + ), + DataColumn2( + label: Text(l10n.appRole), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.dashboardRole), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.createdAt), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _UsersDataSource( + context: context, + users: state.users, + hasMore: state.hasMore, + l10n: l10n, + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + // Handle pagination: fetch next page if needed. + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.users.length && + state.hasMore && + state.status != UserManagementStatus.loading) { + context.read().add( + LoadUsersRequested( + startAfterId: state.cursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildUsersFilterMap( + context.read().state, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noUsersFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.sm, + horizontalMargin: AppSpacing.sm, + ), + ), + ], + ); + }, + ), + ); + } +} + +/// Data source for the paginated user table. +class _UsersDataSource extends DataTableSource { + _UsersDataSource({ + required this.context, + required this.users, + required this.hasMore, + required this.l10n, + }); + + final BuildContext context; + final List users; + final bool hasMore; + final AppLocalizations l10n; + + @override + DataRow? getRow(int index) { + if (index >= users.length) { + return null; + } + final user = users[index]; + return DataRow2( + // We don't implement onSelectChanged because user edits are handled + // via the action buttons, not by navigating to a dedicated edit page. + cells: [ + DataCell(Text(user.email)), + DataCell(Text(user.appRole.name)), + DataCell(Text(user.dashboardRole.name)), + DataCell( + Text( + DateFormat('dd-MM-yyyy').format(user.createdAt.toLocal()), + ), + ), + DataCell( + UserActionButtons( + user: user, + l10n: l10n, + ), + ), + ], + ); + } + + @override + bool get isRowCountApproximate => hasMore; + + @override + int get rowCount => users.length; + + @override + int get selectedRowCount => 0; +} From 94b995c1d3c73c6e32fc5c537664318dc2ffe60c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:22:07 +0100 Subject: [PATCH 12/43] feat(user_management): create user action buttons widget Adds the `UserActionButtons` widget, which provides contextual actions for each user in the data table. This widget displays a popup menu with actions based on the user's role: - "Promote to Publisher" for standard users. - "Demote to User" for publishers. - "Copy User ID" for all non-admin users. It strictly prevents any actions from being displayed or performed on users with the 'admin' role, enhancing security and data integrity. --- .../widgets/user_action_buttons.dart | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 lib/user_management/widgets/user_action_buttons.dart diff --git a/lib/user_management/widgets/user_action_buttons.dart b/lib/user_management/widgets/user_action_buttons.dart new file mode 100644 index 00000000..c88e21d3 --- /dev/null +++ b/lib/user_management/widgets/user_action_buttons.dart @@ -0,0 +1,124 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/user_management/bloc/user_management_bloc.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template user_action_buttons} +/// A widget that displays contextual action buttons for a user in the user +/// management table. +/// +/// Actions are presented in a [PopupMenuButton] and are conditionally +/// displayed based on the user's role and permissions. This follows a similar +/// pattern to [ContentActionButtons] but is tailored for user management. +/// {@endtemplate} +class UserActionButtons extends StatelessWidget { + /// {@macro user_action_buttons} + const UserActionButtons({ + required this.user, + required this.l10n, + super.key, + }); + + /// The user for whom to display actions. + final User user; + + /// The localized strings. + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final overflowMenuItems = >[]; + + // Rule: Do not show any actions for admin users. + if (user.dashboardRole == DashboardUserRole.admin) { + return const SizedBox.shrink(); + } + + // Add contextual actions based on the user's current dashboard role. + switch (user.dashboardRole) { + case DashboardUserRole.none: + overflowMenuItems.add( + PopupMenuItem( + value: 'promote', + child: Row( + children: [ + const Icon(Icons.arrow_upward), + const SizedBox(width: AppSpacing.sm), + Text(l10n.promoteToPublisher), + ], + ), + ), + ); + case DashboardUserRole.publisher: + overflowMenuItems.add( + PopupMenuItem( + value: 'demote', + child: Row( + children: [ + const Icon(Icons.arrow_downward), + const SizedBox(width: AppSpacing.sm), + Text(l10n.demoteToUser), + ], + ), + ), + ); + case DashboardUserRole.admin: + // No actions for admins, handled by the initial check. + break; + } + + // Add the "Copy User ID" action for all non-admin users. + overflowMenuItems.add( + PopupMenuItem( + value: 'copy_id', + child: Row( + children: [ + const Icon(Icons.copy), + const SizedBox(width: AppSpacing.sm), + Text(l10n.copyId), + ], + ), + ), + ); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + iconSize: 20, + icon: const Icon(Icons.more_vert), + tooltip: l10n.moreActions, + onSelected: (value) { + switch (value) { + case 'promote': + context.read().add( + UserDashboardRoleChanged( + userId: user.id, + dashboardRole: DashboardUserRole.publisher, + ), + ); + case 'demote': + context.read().add( + UserDashboardRoleChanged( + userId: user.id, + dashboardRole: DashboardUserRole.none, + ), + ); + case 'copy_id': + Clipboard.setData(ClipboardData(text: user.id)); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.idCopiedToClipboard(user.id))), + ); + } + }, + itemBuilder: (context) => overflowMenuItems, + ), + ], + ); + } +} From 1d26d61e026e67d5bb08411b9333c93145e9570c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:24:01 +0100 Subject: [PATCH 13/43] feat(user_management): create user filter dialog state Adds the `UserFilterDialogState` class. This state will hold the temporary filter selections within the user filter dialog before they are applied to the main `UserFilterBloc`. --- .../bloc/user_filter_dialog_state.dart | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) 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 db6aa293..45d5fa3c 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 @@ -1,10 +1,53 @@ part of 'user_filter_dialog_bloc.dart'; -sealed class UserFilterDialogState extends Equatable { - const UserFilterDialogState(); - +/// {@template user_filter_dialog_state} +/// The state for the [UserFilterDialogBloc]. +/// +/// This state holds the temporary filter selections made by the user within +/// the filter dialog before they are applied to the main user list. +/// {@endtemplate} +final class UserFilterDialogState extends Equatable { + /// {@macro user_filter_dialog_state} + const UserFilterDialogState({ + this.searchQuery = '', + this.selectedStatus = ContentStatus.active, + this.selectedAppRoles = const [], + this.selectedDashboardRoles = const [], + }); + + /// The current text in the search query field. + final String searchQuery; + + /// The single content status to be included in the filter. + final ContentStatus selectedStatus; + + /// The list of app roles to be included in the filter. + final List selectedAppRoles; + + /// The list of dashboard roles to be included in the filter. + final List selectedDashboardRoles; + + /// Creates a copy of this [UserFilterDialogState] with updated values. + UserFilterDialogState copyWith({ + String? searchQuery, + ContentStatus? selectedStatus, + List? selectedAppRoles, + List? selectedDashboardRoles, + }) { + return UserFilterDialogState( + searchQuery: searchQuery ?? this.searchQuery, + selectedStatus: selectedStatus ?? this.selectedStatus, + selectedAppRoles: selectedAppRoles ?? this.selectedAppRoles, + selectedDashboardRoles: + selectedDashboardRoles ?? this.selectedDashboardRoles, + ); + } + @override - List get props => []; + List get props => [ + searchQuery, + selectedStatus, + selectedAppRoles, + selectedDashboardRoles, + ]; } - -final class UserFilterDialogInitial extends UserFilterDialogState {} From 17ba8449d0cc73b6ea0edf41140fa7568883c183 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:26:05 +0100 Subject: [PATCH 14/43] feat(user_management): create user filter dialog events Adds the `UserFilterDialogEvent` sealed class and its concrete implementations. These events will manage the temporary state of the user filter dialog, including initialization, changes to search query and roles, and resetting the filters. --- .../bloc/user_filter_dialog_event.dart | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) 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 3ee68498..eed34339 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 @@ -1,8 +1,57 @@ part of 'user_filter_dialog_bloc.dart'; +/// Base class for all events related to the [UserFilterDialogBloc]. sealed class UserFilterDialogEvent extends Equatable { const UserFilterDialogEvent(); @override - List get props => []; + List get props => []; +} + +/// Event to initialize the filter dialog's state from the main user filter BLoC. +final class UserFilterDialogInitialized extends UserFilterDialogEvent { + const UserFilterDialogInitialized({ + required this.userFilterState, + }); + + final UserFilterState userFilterState; + + @override + List get props => [userFilterState]; +} + +/// Event to update the temporary search query in the dialog. +final class UserFilterDialogSearchQueryChanged extends UserFilterDialogEvent { + const UserFilterDialogSearchQueryChanged(this.query); + + final String query; + + @override + List get props => [query]; +} + +/// Event to update the temporary selected app roles in the dialog. +final class UserFilterDialogAppRolesChanged extends UserFilterDialogEvent { + const UserFilterDialogAppRolesChanged(this.appRoles); + + final List appRoles; + + @override + List get props => [appRoles]; +} + +/// Event to update the temporary selected dashboard roles in the dialog. +final class UserFilterDialogDashboardRolesChanged + extends UserFilterDialogEvent { + const UserFilterDialogDashboardRolesChanged(this.dashboardRoles); + + final List dashboardRoles; + + @override + List get props => [dashboardRoles]; +} + +/// Event to reset all temporary filter selections in the dialog. +final class UserFilterDialogReset extends UserFilterDialogEvent { + const UserFilterDialogReset(); } From 9ab157acdaae271445b7e6d6fa0528658001ca85 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:27:32 +0100 Subject: [PATCH 15/43] feat(user_management): create user filter dialog bloc Adds the `UserFilterDialogBloc` to manage the temporary state of the user filter dialog. This BLoC is initialized with the current filters, handles user changes within the dialog, and provides a mechanism to reset the selections. --- .../bloc/user_filter_dialog_bloc.dart | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) 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 7977af7a..5bf8598e 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 @@ -1,13 +1,73 @@ 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'; part 'user_filter_dialog_event.dart'; part 'user_filter_dialog_state.dart'; -class UserFilterDialogBloc extends Bloc { - UserFilterDialogBloc() : super(UserFilterDialogInitial()) { - on((event, emit) { - // TODO: implement event handler - }); +/// {@template user_filter_dialog_bloc} +/// Manages the temporary state and logic for the [UserFilterDialog]. +/// +/// This BLoC is initialized with the current state of the main +/// [UserFilterBloc]. It allows users to modify filter criteria in a temporary +/// state. When the user applies the changes, the new state is dispatched +/// back to the main [UserFilterBloc]. +/// {@endtemplate} +class UserFilterDialogBloc + extends Bloc { + /// {@macro user_filter_dialog_bloc} + UserFilterDialogBloc() : super(const UserFilterDialogState()) { + on(_onFilterDialogInitialized); + on(_onSearchQueryChanged); + on(_onAppRolesChanged); + on(_onDashboardRolesChanged); + on(_onFilterDialogReset); + } + + /// Initializes the dialog's state from the main [UserFilterBloc]'s state. + void _onFilterDialogInitialized( + UserFilterDialogInitialized event, + Emitter emit, + ) { + emit( + state.copyWith( + searchQuery: event.userFilterState.searchQuery, + selectedAppRoles: event.userFilterState.selectedAppRoles, + selectedDashboardRoles: event.userFilterState.selectedDashboardRoles, + ), + ); + } + + /// Updates the temporary search query. + void _onSearchQueryChanged( + UserFilterDialogSearchQueryChanged event, + Emitter emit, + ) { + emit(state.copyWith(searchQuery: event.query)); + } + + /// Updates the temporary selected app roles. + void _onAppRolesChanged( + UserFilterDialogAppRolesChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedAppRoles: event.appRoles)); + } + + /// Updates the temporary selected dashboard roles. + void _onDashboardRolesChanged( + UserFilterDialogDashboardRolesChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedDashboardRoles: event.dashboardRoles)); + } + + /// Resets all temporary filter selections in the dialog. + void _onFilterDialogReset( + UserFilterDialogReset event, + Emitter emit, + ) { + emit(const UserFilterDialogState()); } } From bb5d78840902d42206118a8ce9f2f832c9d0de5a Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:30:20 +0100 Subject: [PATCH 16/43] feat(user_management): create user filter dialog widget Adds the `UserFilterDialog`, a full-screen dialog for filtering the user list. This dialog allows administrators to filter users by: - Email (via a search field) - App Role (multi-select) - Dashboard Role (multi-select) It uses `UserFilterDialogBloc` to manage its temporary state and applies the selected filters to the main `UserFilterBloc` when the "Apply" button is pressed. It also includes a "Reset" button to clear all filters. --- .../user_filter_dialog.dart | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 lib/user_management/widgets/user_filter_dialog/user_filter_dialog.dart 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 new file mode 100644 index 00000000..42c11f4e --- /dev/null +++ b/lib/user_management/widgets/user_filter_dialog/user_filter_dialog.dart @@ -0,0 +1,194 @@ +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/user_management/bloc/user_filter/user_filter_bloc.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'; + +/// {@template user_filter_dialog} +/// A full-screen dialog for applying filters to the user management list. +/// +/// This dialog provides a search text field for user emails and searchable +/// selection inputs for app and dashboard roles. It uses [UserFilterDialogBloc] +/// to manage its temporary state and applies the final filters to the main +/// [UserFilterBloc]. +/// {@endtemplate} +class UserFilterDialog extends StatefulWidget { + /// {@macro user_filter_dialog} + const UserFilterDialog({super.key}); + + @override + State createState() => _UserFilterDialogState(); +} + +class _UserFilterDialogState extends State { + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// Dispatches the filter applied event to the main [UserFilterBloc]. + void _dispatchFilterApplied(UserFilterDialogState filterDialogState) { + context.read().add( + UserFilterApplied( + searchQuery: filterDialogState.searchQuery, + selectedAppRoles: filterDialogState.selectedAppRoles, + selectedDashboardRoles: filterDialogState.selectedDashboardRoles, + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + return BlocBuilder( + builder: (context, filterDialogState) { + // Sync the text controller with the BLoC state. + if (_searchController.text != filterDialogState.searchQuery) { + _searchController.text = filterDialogState.searchQuery; + _searchController.selection = TextSelection.fromPosition( + TextPosition(offset: _searchController.text.length), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(l10n.filterUsers), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: l10n.resetFiltersButtonText, + onPressed: () { + // Dispatch a reset event to the main filter BLoC and close. + context.read().add(const UserFilterReset()); + Navigator.of(context).pop(); + }, + ), + IconButton( + icon: const Icon(Icons.check), + tooltip: l10n.applyFilters, + onPressed: () { + // Apply the current temporary filters and close. + _dispatchFilterApplied(filterDialogState); + Navigator.of(context).pop(); + }, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search field for user email. + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: l10n.search, + hintText: l10n.searchByUserEmail, + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + ), + onChanged: (query) { + context.read().add( + UserFilterDialogSearchQueryChanged(query), + ); + }, + ), + 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, + ), + 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(), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +/// Extension to provide localized names for AppUserRole. +extension AppUserRoleL10n on AppUserRole { + /// Returns the localized name of the app user role. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case AppUserRole.guestUser: + return l10n.guestUserRole; + case AppUserRole.standardUser: + return l10n.standardUserRole; + case AppUserRole.premiumUser: + return l10n.premiumUserRole; + } + } +} + +/// Extension to provide localized names for DashboardUserRole. +extension DashboardUserRoleL10n on DashboardUserRole { + /// Returns the localized name of the dashboard user role. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case DashboardUserRole.admin: + return l10n.adminRole; + case DashboardUserRole.publisher: + return l10n.publisherRole; + case DashboardUserRole.none: + return l10n.none; + } + } +} From 8f40ff3f3dc6241a950060229a076e6e1a1cbadd Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:34:07 +0100 Subject: [PATCH 17/43] feat(user_management): provide user management blocs in app Updates `app.dart` to accept the `usersRepository` and provide the new `UserManagementBloc` and `UserFilterBloc` via the existing `MultiBlocProvider`. This correctly integrates the new BLoCs into the application's dependency injection structure without altering the established architecture in `bootstrap.dart`. --- lib/app/view/app.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 2fa742c2..81135068 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -20,6 +20,8 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_manage import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/bloc/local_ads_management_bloc.dart'; 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/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/shared/services/pending_deletions_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:go_router/go_router.dart'; @@ -41,6 +43,7 @@ class App extends StatelessWidget { required DataRepository countriesRepository, required DataRepository languagesRepository, required DataRepository localAdsRepository, + required DataRepository usersRepository, required KVStorageService storageService, required AppEnvironment environment, required PendingDeletionsService pendingDeletionsService, @@ -57,6 +60,7 @@ class App extends StatelessWidget { _countriesRepository = countriesRepository, _languagesRepository = languagesRepository, _localAdsRepository = localAdsRepository, + _usersRepository = usersRepository, _environment = environment, _pendingDeletionsService = pendingDeletionsService; @@ -72,6 +76,7 @@ class App extends StatelessWidget { final DataRepository _countriesRepository; final DataRepository _languagesRepository; final DataRepository _localAdsRepository; + final DataRepository _usersRepository; final KVStorageService _kvStorageService; final AppEnvironment _environment; @@ -93,6 +98,7 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _countriesRepository), RepositoryProvider.value(value: _languagesRepository), RepositoryProvider.value(value: _localAdsRepository), + RepositoryProvider.value(value: _usersRepository), RepositoryProvider.value(value: _kvStorageService), RepositoryProvider( create: (context) => const ThrottledFetchingService(), @@ -163,6 +169,15 @@ class App extends StatelessWidget { sourcesRepository: context.read>(), ), ), + // The UserFilterBloc is provided here to be available for both the + // UserManagementBloc and the UI components. + BlocProvider(create: (_) => UserFilterBloc()), + BlocProvider( + create: (context) => UserManagementBloc( + usersRepository: context.read>(), + userFilterBloc: context.read(), + ), + ), ], child: _AppView( authenticationRepository: _authenticationRepository, From f102018d1e98cda17fca1ddc91881442b68c1d26 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:35:45 +0100 Subject: [PATCH 18/43] refactor(user_management): reorganize user_filter bloc directory structure - Move user_filter_bloc.dart, user_filter_event.dart, and user_filter_state.dart into a new user_filter directory - This change improves the folder structure and makes it more consistent with common bloc patterns --- .../bloc/{bloc => user_filter}/user_filter_bloc.dart | 0 .../bloc/{bloc => user_filter}/user_filter_event.dart | 0 .../bloc/{bloc => user_filter}/user_filter_state.dart | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename lib/user_management/bloc/{bloc => user_filter}/user_filter_bloc.dart (100%) rename lib/user_management/bloc/{bloc => user_filter}/user_filter_event.dart (100%) rename lib/user_management/bloc/{bloc => user_filter}/user_filter_state.dart (100%) diff --git a/lib/user_management/bloc/bloc/user_filter_bloc.dart b/lib/user_management/bloc/user_filter/user_filter_bloc.dart similarity index 100% rename from lib/user_management/bloc/bloc/user_filter_bloc.dart rename to lib/user_management/bloc/user_filter/user_filter_bloc.dart diff --git a/lib/user_management/bloc/bloc/user_filter_event.dart b/lib/user_management/bloc/user_filter/user_filter_event.dart similarity index 100% rename from lib/user_management/bloc/bloc/user_filter_event.dart rename to lib/user_management/bloc/user_filter/user_filter_event.dart diff --git a/lib/user_management/bloc/bloc/user_filter_state.dart b/lib/user_management/bloc/user_filter/user_filter_state.dart similarity index 100% rename from lib/user_management/bloc/bloc/user_filter_state.dart rename to lib/user_management/bloc/user_filter/user_filter_state.dart From 5c66e106e7b77be5490c0bab569f409e47f97805 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:37:11 +0100 Subject: [PATCH 19/43] feat(user_management): add user management to navigation shell Updates the `AppShell` to include a new `NavigationDestination` for the "User Management" feature, making it accessible from the main navigation rail/drawer. --- lib/app/view/app_shell.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/app/view/app_shell.dart b/lib/app/view/app_shell.dart index e26f4839..7b82dcbf 100644 --- a/lib/app/view/app_shell.dart +++ b/lib/app/view/app_shell.dart @@ -50,6 +50,11 @@ class AppShell extends StatelessWidget { selectedIcon: const Icon(Icons.folder), label: l10n.contentManagement, ), + NavigationDestination( + icon: const Icon(Icons.people_outline), + selectedIcon: const Icon(Icons.people), + label: l10n.userManagement, + ), NavigationDestination( icon: const Icon(Icons.settings_applications_outlined), selectedIcon: const Icon(Icons.settings_applications), From 3dfd1cda3247697a145300ddad1f442966065e10 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:39:29 +0100 Subject: [PATCH 20/43] feat(user_management): add user management routes to router Updates `router.dart` to integrate the new User Management feature. This change adds a new `StatefulShellBranch` for the user management section and defines the `GoRoute` for the main `UserManagementPage`. It also includes a nested route for the `UserFilterDialog`, which is presented as a full-screen dialog and is provided with its own `UserFilterDialogBloc`. --- lib/router/router.dart | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/router/router.dart b/lib/router/router.dart index 5ca41750..39269236 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -39,6 +39,10 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_manage import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/widgets/local_ads_filter_dialog/local_ads_filter_dialog.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/overview/view/overview_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.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/view/user_management_page.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:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_filter_dialog/user_filter_dialog.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/settings/view/settings_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/searchable_selection_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/selection_page_arguments.dart'; @@ -169,6 +173,41 @@ GoRouter createRouter({ ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.userManagement, + name: Routes.userManagementName, + builder: (context, state) => const UserManagementPage(), + routes: [ + // Route for the UserFilterDialog. + GoRoute( + path: Routes.userFilterDialog, + name: Routes.userFilterDialogName, + pageBuilder: (context, state) { + final args = state.extra! as Map; + final userFilterState = + args['userFilterState'] as UserFilterState; + + return MaterialPage( + fullscreenDialog: true, + child: BlocProvider( + create: (providerContext) => + UserFilterDialogBloc() + ..add( + UserFilterDialogInitialized( + userFilterState: userFilterState, + ), + ), + child: const UserFilterDialog(), + ), + ); + }, + ), + ], + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( From cb82c6dfe67823b8753100047f7201b229db0ee7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:46:42 +0100 Subject: [PATCH 21/43] feat(l10n): add user management translations - Add new translations for user management features in Arabic and English - Include various elements such as navigation labels, page descriptions, table headers, and action buttons - Cover different aspects of user management, including filtering, promoting, and demoting users --- lib/l10n/arb/app_ar.arb | 64 +++++++++++++++++++++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 3250489c..5ea4b5c3 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1923,5 +1923,69 @@ "logoUrl": "رابط الشعار", "@logoUrl": { "description": "تسمية حقل إدخال رابط شعار المصدر" + }, + "userManagement": "إدارة المستخدمين", + "@userManagement": { + "description": "تسمية عنصر التنقل لإدارة المستخدمين" + }, + "userManagementPageDescription": "إدارة مستخدمي النظام، بما في ذلك أدوارهم وأذوناتهم.", + "@userManagementPageDescription": { + "description": "وصف صفحة إدارة المستخدمين" + }, + "loadingUsers": "جاري تحميل المستخدمين", + "@loadingUsers": { + "description": "عنوان حالة تحميل المستخدمين" + }, + "noUsersFound": "لم يتم العثور على مستخدمين.", + "@noUsersFound": { + "description": "رسالة عند عدم العثور على مستخدمين" + }, + "email": "البريد الإلكتروني", + "@email": { + "description": "رأس العمود لبريد المستخدم الإلكتروني" + }, + "appRole": "دور التطبيق", + "@appRole": { + "description": "رأس العمود لدور المستخدم في التطبيق" + }, + "dashboardRole": "دور لوحة التحكم", + "@dashboardRole": { + "description": "رأس العمود لدور المستخدم في لوحة التحكم" + }, + "createdAt": "تاريخ الإنشاء", + "@createdAt": { + "description": "رأس العمود لتاريخ الإنشاء" + }, + "promoteToPublisher": "ترقية إلى ناشر", + "@promoteToPublisher": { + "description": "إجراء لترقية مستخدم إلى دور ناشر." + }, + "demoteToUser": "تخفيض إلى مستخدم", + "@demoteToUser": { + "description": "إجراء لتخفيض ناشر إلى دور مستخدم عادي." + }, + "adminRole": "مسؤول", + "@adminRole": { + "description": "الاسم المترجم لـ DashboardUserRole.admin" + }, + "publisherRole": "ناشر", + "@publisherRole": { + "description": "الاسم المترجم لـ DashboardUserRole.publisher" + }, + "filterUsers": "تصفية المستخدمين", + "@filterUsers": { + "description": "عنوان مربع حوار التصفية عند تصفية المستخدمين." + }, + "searchByUserEmail": "البحث بالبريد الإلكتروني للمستخدم...", + "@searchByUserEmail": { + "description": "نص تلميح حقل البحث عن المستخدم." + }, + "selectAppRoles": "اختر أدوار التطبيق", + "@selectAppRoles": { + "description": "نص تلميح لاختيار أدوار التطبيق في مربع حوار التصفية." + }, + "selectDashboardRoles": "اختر أدوار لوحة التحكم", + "@selectDashboardRoles": { + "description": "نص تلميح لاختيار أدوار لوحة التحكم في مربع حوار التصفية." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 28e8f938..a8373429 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1919,5 +1919,69 @@ "logoUrl": "Logo URL", "@logoUrl": { "description": "Label for the source logo URL input field" + }, + "userManagement": "User Management", + "@userManagement": { + "description": "Label for the user management navigation item" + }, + "userManagementPageDescription": "Manage system users, including their roles and permissions.", + "@userManagementPageDescription": { + "description": "Description for the User Management page" + }, + "loadingUsers": "Loading Users", + "@loadingUsers": { + "description": "Headline for loading state of users" + }, + "noUsersFound": "No users found.", + "@noUsersFound": { + "description": "Message when no users are found" + }, + "email": "Email", + "@email": { + "description": "Column header for user email" + }, + "appRole": "App Role", + "@appRole": { + "description": "Column header for user app role" + }, + "dashboardRole": "Dashboard Role", + "@dashboardRole": { + "description": "Column header for user dashboard role" + }, + "createdAt": "Created At", + "@createdAt": { + "description": "Column header for creation date" + }, + "promoteToPublisher": "Promote to Publisher", + "@promoteToPublisher": { + "description": "Action to promote a user to a publisher role." + }, + "demoteToUser": "Demote to User", + "@demoteToUser": { + "description": "Action to demote a publisher back to a standard user role." + }, + "adminRole": "Admin", + "@adminRole": { + "description": "Localized name for DashboardUserRole.admin" + }, + "publisherRole": "Publisher", + "@publisherRole": { + "description": "Localized name for DashboardUserRole.publisher" + }, + "filterUsers": "Filter Users", + "@filterUsers": { + "description": "Title for the filter dialog when filtering users." + }, + "searchByUserEmail": "Search by user email...", + "@searchByUserEmail": { + "description": "Hint text for the user search field." + }, + "selectAppRoles": "Select App Roles", + "@selectAppRoles": { + "description": "Hint text for selecting app roles in a filter dialog." + }, + "selectDashboardRoles": "Select Dashboard Roles", + "@selectDashboardRoles": { + "description": "Hint text for selecting dashboard roles in a filter dialog." } } \ No newline at end of file From f6d1c6bdfb6ee18df66407dc45e49b090b26e146 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 10:47:14 +0100 Subject: [PATCH 22/43] feat(l10n): add user management localization - Add new localization strings for user management feature - Implement translations for Arabic and English languages - Cover various aspects of user management including: * Navigation labels * Page descriptions * User attributes (email, roles, creation date) * Action labels (promote, demote) * Filter dialog elements --- lib/l10n/app_localizations.dart | 96 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 49 +++++++++++++++ lib/l10n/app_localizations_en.dart | 49 +++++++++++++++ 3 files changed, 194 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 898cedd0..bbf0e3b0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2845,6 +2845,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Logo URL'** String get logoUrl; + + /// Label for the user management navigation item + /// + /// In en, this message translates to: + /// **'User Management'** + String get userManagement; + + /// Description for the User Management page + /// + /// In en, this message translates to: + /// **'Manage system users, including their roles and permissions.'** + String get userManagementPageDescription; + + /// Headline for loading state of users + /// + /// In en, this message translates to: + /// **'Loading Users'** + String get loadingUsers; + + /// Message when no users are found + /// + /// In en, this message translates to: + /// **'No users found.'** + String get noUsersFound; + + /// Column header for user email + /// + /// In en, this message translates to: + /// **'Email'** + String get email; + + /// Column header for user app role + /// + /// In en, this message translates to: + /// **'App Role'** + String get appRole; + + /// Column header for user dashboard role + /// + /// In en, this message translates to: + /// **'Dashboard Role'** + String get dashboardRole; + + /// Column header for creation date + /// + /// In en, this message translates to: + /// **'Created At'** + String get createdAt; + + /// Action to promote a user to a publisher role. + /// + /// In en, this message translates to: + /// **'Promote to Publisher'** + String get promoteToPublisher; + + /// Action to demote a publisher back to a standard user role. + /// + /// In en, this message translates to: + /// **'Demote to User'** + String get demoteToUser; + + /// Localized name for DashboardUserRole.admin + /// + /// In en, this message translates to: + /// **'Admin'** + String get adminRole; + + /// Localized name for DashboardUserRole.publisher + /// + /// In en, this message translates to: + /// **'Publisher'** + String get publisherRole; + + /// Title for the filter dialog when filtering users. + /// + /// In en, this message translates to: + /// **'Filter Users'** + String get filterUsers; + + /// Hint text for the user search field. + /// + /// In en, this message translates to: + /// **'Search by user email...'** + String get searchByUserEmail; + + /// Hint text for selecting app roles in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select App Roles'** + String get selectAppRoles; + + /// Hint text for selecting dashboard roles in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Dashboard Roles'** + String get selectDashboardRoles; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index c1e873ff..a05e0bb8 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -1515,4 +1515,53 @@ class AppLocalizationsAr extends AppLocalizations { @override String get logoUrl => 'رابط الشعار'; + + @override + String get userManagement => 'إدارة المستخدمين'; + + @override + String get userManagementPageDescription => + 'إدارة مستخدمي النظام، بما في ذلك أدوارهم وأذوناتهم.'; + + @override + String get loadingUsers => 'جاري تحميل المستخدمين'; + + @override + String get noUsersFound => 'لم يتم العثور على مستخدمين.'; + + @override + String get email => 'البريد الإلكتروني'; + + @override + String get appRole => 'دور التطبيق'; + + @override + String get dashboardRole => 'دور لوحة التحكم'; + + @override + String get createdAt => 'تاريخ الإنشاء'; + + @override + String get promoteToPublisher => 'ترقية إلى ناشر'; + + @override + String get demoteToUser => 'تخفيض إلى مستخدم'; + + @override + String get adminRole => 'مسؤول'; + + @override + String get publisherRole => 'ناشر'; + + @override + String get filterUsers => 'تصفية المستخدمين'; + + @override + String get searchByUserEmail => 'البحث بالبريد الإلكتروني للمستخدم...'; + + @override + String get selectAppRoles => 'اختر أدوار التطبيق'; + + @override + String get selectDashboardRoles => 'اختر أدوار لوحة التحكم'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 227bb227..5e04f035 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1521,4 +1521,53 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logoUrl => 'Logo URL'; + + @override + String get userManagement => 'User Management'; + + @override + String get userManagementPageDescription => + 'Manage system users, including their roles and permissions.'; + + @override + String get loadingUsers => 'Loading Users'; + + @override + String get noUsersFound => 'No users found.'; + + @override + String get email => 'Email'; + + @override + String get appRole => 'App Role'; + + @override + String get dashboardRole => 'Dashboard Role'; + + @override + String get createdAt => 'Created At'; + + @override + String get promoteToPublisher => 'Promote to Publisher'; + + @override + String get demoteToUser => 'Demote to User'; + + @override + String get adminRole => 'Admin'; + + @override + String get publisherRole => 'Publisher'; + + @override + String get filterUsers => 'Filter Users'; + + @override + String get searchByUserEmail => 'Search by user email...'; + + @override + String get selectAppRoles => 'Select App Roles'; + + @override + String get selectDashboardRoles => 'Select Dashboard Roles'; } From 21b185f699cd6e1044f158a2969dab48c221a3ec Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:01:43 +0100 Subject: [PATCH 23/43] docs(README): add user and role management section - Include new section detailing user and role management features - Highlight granular user management capabilities - Emphasize secure administrative access and delegation of content creation responsibilities - Improve documentation structure for better overview of system features --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fca8d19..218f918e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,19 @@ Manage the entire lifecycle of your content from a single, intuitive interface. +
+👥 User & Role Management + +### 👥 Granular User & Role Management +Effortlessly manage your entire user base with a dedicated user management system. View all registered users, filter them by email or role, and dynamically adjust their dashboard permissions. +- **Full User Roster:** See a comprehensive list of all users, including their email, app subscription level, and current dashboard role. +- **Dynamic Role Promotion:** Promote trusted users to a "Publisher" role, granting them content management capabilities without full administrative access. +- **Powerful Filtering:** Quickly locate specific users or user segments with multi-faceted filtering by email, app role, and dashboard role. +> **Your Advantage:** Delegate content creation responsibilities securely, build out your editorial team, and maintain a clear overview of all system users and their permissions, all from a single, centralized interface. + +
+ +
⚙️ App Monetization & Remote Control @@ -60,7 +73,7 @@ Dynamically control the mobile app's behavior and operational state directly fro ### 🔐 Secure Administrative Access A complete and secure user authentication system is built-in for your editorial and administrative teams. - **Modern, Passwordless Sign-In:** Ensures that only authorized personnel can access the dashboard using a secure and easy-to-use email-based verification system. -> **Your Advantage:** The security and user management for your administrative team is already handled, providing peace of mind from day one. +> **Your Advantage:** The security and access control for your administrative team is already handled, providing peace of mind from day one. --- From 214ef817a94f3a87eaee627a9db27977adfdf304 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:14:54 +0100 Subject: [PATCH 24/43] refactor(user_management): improve code maintainability - Replace hardcoded value with constant from AppConstants - Add missing import for UI kit --- lib/user_management/bloc/user_management_bloc.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart index 42993e4d..5cf1a95b 100644 --- a/lib/user_management/bloc/user_management_bloc.dart +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -6,6 +6,7 @@ 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:ui_kit/ui_kit.dart'; part 'user_management_event.dart'; part 'user_management_state.dart'; @@ -34,7 +35,7 @@ class UserManagementBloc _filterSubscription = _userFilterBloc.stream.listen((_) { add( LoadUsersRequested( - limit: kDefaultRowsPerPage, + limit: AppConstants.kDefaultRowsPerPage, forceRefresh: true, filter: buildUsersFilterMap(_userFilterBloc.state), ), From 84aa764aa7e6aef38e37d6aee657bc11906dacc0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:15:35 +0100 Subject: [PATCH 25/43] fix(user_management): update localization import path - Replace direct import of AppLocalizations with flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations - This change ensures consistency in localization import across the user management feature --- lib/user_management/view/users_page.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index cf213255..71e64ff2 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -2,6 +2,7 @@ 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/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/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; From 5b40c8594d39122cd90897583a1d43d6d1f948ac Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:15:52 +0100 Subject: [PATCH 26/43] fix(user_management): update import path for AppLocalizations - Replace incorrect import path for flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart - Ensure proper localization functionality in user_action_buttons.dart --- lib/user_management/widgets/user_action_buttons.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/user_management/widgets/user_action_buttons.dart b/lib/user_management/widgets/user_action_buttons.dart index c88e21d3..6a598af1 100644 --- a/lib/user_management/widgets/user_action_buttons.dart +++ b/lib/user_management/widgets/user_action_buttons.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/user_management/bloc/user_management_bloc.dart'; import 'package:ui_kit/ui_kit.dart'; From 246e975f3b71bb0e5c200f7c3a8a12d42cee8a2f Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:19:16 +0100 Subject: [PATCH 27/43] feat(extensions): add new localization exports - Add banner_ad_shape_l10n.dart and dashboard_user_role_l10n.dart - These new exports will enable localization for additional entities --- .../extensions/dashboard_user_role_l10n.dart | 21 +++++++++++++++++++ lib/shared/extensions/extensions.dart | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 lib/shared/extensions/dashboard_user_role_l10n.dart diff --git a/lib/shared/extensions/dashboard_user_role_l10n.dart b/lib/shared/extensions/dashboard_user_role_l10n.dart new file mode 100644 index 00000000..e3e50c24 --- /dev/null +++ b/lib/shared/extensions/dashboard_user_role_l10n.dart @@ -0,0 +1,21 @@ +import 'package:core/core.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; + +/// {@template dashboard_user_role_l10n} +/// Extension on [DashboardUserRole] to provide localized string representations. +/// {@endtemplate} +extension DashboardUserRoleL10n on DashboardUserRole { + /// Returns the localized name for a [DashboardUserRole]. + String l10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case DashboardUserRole.admin: + return l10n.adminRole; + case DashboardUserRole.publisher: + return l10n.publisherRole; + case DashboardUserRole.none: + return l10n.none; + } + } +} diff --git a/lib/shared/extensions/extensions.dart b/lib/shared/extensions/extensions.dart index 6178668a..f0f68e44 100644 --- a/lib/shared/extensions/extensions.dart +++ b/lib/shared/extensions/extensions.dart @@ -1,7 +1,9 @@ export 'ad_platform_type_l10n.dart'; export 'ad_type_l10n.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 'feed_decorator_type_l10n.dart'; export 'in_article_ad_slot_type_l10n.dart'; export 'local_ad_to_ad_type.dart'; From 15402115dd7324304da64768fc206ee6ee9d60ff Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:23:42 +0100 Subject: [PATCH 28/43] fix(user_management): resolve ambiguous extension errors Removes the local `AppUserRoleL10n` and `DashboardUserRoleL10n` extensions from `user_filter_dialog.dart`. These local definitions were causing build errors due to ambiguity with the centralized extensions in `lib/shared/extensions/`. This change ensures that the dialog uses the shared, canonical extensions, resolving the conflict and adhering to DRY principles. --- .../user_filter_dialog.dart | 32 ------------------- 1 file changed, 32 deletions(-) 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 42c11f4e..cb203187 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 @@ -160,35 +160,3 @@ class _UserFilterDialogState extends State { ); } } - -/// Extension to provide localized names for AppUserRole. -extension AppUserRoleL10n on AppUserRole { - /// Returns the localized name of the app user role. - String l10n(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - switch (this) { - case AppUserRole.guestUser: - return l10n.guestUserRole; - case AppUserRole.standardUser: - return l10n.standardUserRole; - case AppUserRole.premiumUser: - return l10n.premiumUserRole; - } - } -} - -/// Extension to provide localized names for DashboardUserRole. -extension DashboardUserRoleL10n on DashboardUserRole { - /// Returns the localized name of the dashboard user role. - String l10n(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - switch (this) { - case DashboardUserRole.admin: - return l10n.adminRole; - case DashboardUserRole.publisher: - return l10n.publisherRole; - case DashboardUserRole.none: - return l10n.none; - } - } -} From 16e84e8f01bb933f2a0b4e237c05c7f008f22c40 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:24:14 +0100 Subject: [PATCH 29/43] lint: misc --- lib/app/view/app.dart | 4 ++-- lib/router/router.dart | 6 +++--- lib/user_management/bloc/user_filter/user_filter_bloc.dart | 1 + lib/user_management/view/users_page.dart | 1 - lib/user_management/widgets/user_action_buttons.dart | 2 +- .../user_filter_dialog/bloc/user_filter_dialog_bloc.dart | 1 + 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 81135068..6c21c23e 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -20,10 +20,10 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_manage import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/bloc/local_ads_management_bloc.dart'; 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/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/shared/services/pending_deletions_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'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; import 'package:logging/logging.dart'; diff --git a/lib/router/router.dart b/lib/router/router.dart index 39269236..a2b10a2a 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -39,13 +39,13 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_manage import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/widgets/local_ads_filter_dialog/local_ads_filter_dialog.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/overview/view/overview_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/settings/view/settings_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/searchable_selection_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/selection_page_arguments.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/view/user_management_page.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:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_filter_dialog/user_filter_dialog.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/settings/view/settings_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/searchable_selection_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/selection_page_arguments.dart'; import 'package:go_router/go_router.dart'; /// Creates and configures the GoRouter instance for the application. 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 a9cac08f..d0e4d72c 100644 --- a/lib/user_management/bloc/user_filter/user_filter_bloc.dart +++ b/lib/user_management/bloc/user_filter/user_filter_bloc.dart @@ -1,6 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart' show UserManagementBloc; part 'user_filter_event.dart'; part 'user_filter_state.dart'; diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index 71e64ff2..d56bc89f 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -4,7 +4,6 @@ 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/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/bloc/user_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_action_buttons.dart'; diff --git a/lib/user_management/widgets/user_action_buttons.dart b/lib/user_management/widgets/user_action_buttons.dart index 6a598af1..6704e858 100644 --- a/lib/user_management/widgets/user_action_buttons.dart +++ b/lib/user_management/widgets/user_action_buttons.dart @@ -2,8 +2,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart' show ContentActionButtons; 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/user_management/bloc/user_management_bloc.dart'; import 'package:ui_kit/ui_kit.dart'; 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 5bf8598e..29d8774f 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,7 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_filter/user_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_filter_dialog/user_filter_dialog.dart' show UserFilterDialog; part 'user_filter_dialog_event.dart'; part 'user_filter_dialog_state.dart'; From 82797221587d05d4a3eb18521a5469a8e3d87bc0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:24:35 +0100 Subject: [PATCH 30/43] stye: format --- lib/router/router.dart | 9 ++++----- .../bloc/user_filter/user_filter_bloc.dart | 15 +++++++++------ .../bloc/user_filter/user_filter_event.dart | 8 ++++---- .../widgets/user_action_buttons.dart | 3 ++- .../bloc/user_filter_dialog_bloc.dart | 3 ++- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index a2b10a2a..6e8e9943 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -193,12 +193,11 @@ GoRouter createRouter({ fullscreenDialog: true, child: BlocProvider( create: (providerContext) => - UserFilterDialogBloc() - ..add( - UserFilterDialogInitialized( - userFilterState: userFilterState, - ), + UserFilterDialogBloc()..add( + UserFilterDialogInitialized( + userFilterState: userFilterState, ), + ), child: const UserFilterDialog(), ), ); 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 d0e4d72c..686e4e87 100644 --- a/lib/user_management/bloc/user_filter/user_filter_bloc.dart +++ b/lib/user_management/bloc/user_filter/user_filter_bloc.dart @@ -1,7 +1,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_management_bloc.dart' show UserManagementBloc; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart' + show UserManagementBloc; part 'user_filter_event.dart'; part 'user_filter_state.dart'; @@ -60,10 +61,12 @@ class UserFilterBloc extends Bloc { UserFilterApplied event, Emitter emit, ) { - emit(state.copyWith( - searchQuery: event.searchQuery, - selectedAppRoles: event.selectedAppRoles, - selectedDashboardRoles: event.selectedDashboardRoles, - ),); + emit( + state.copyWith( + searchQuery: event.searchQuery, + selectedAppRoles: event.selectedAppRoles, + selectedDashboardRoles: event.selectedDashboardRoles, + ), + ); } } 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 45eed6a9..fa8e3123 100644 --- a/lib/user_management/bloc/user_filter/user_filter_event.dart +++ b/lib/user_management/bloc/user_filter/user_filter_event.dart @@ -58,8 +58,8 @@ final class UserFilterApplied extends UserFilterEvent { @override List get props => [ - searchQuery, - selectedAppRoles, - selectedDashboardRoles, - ]; + searchQuery, + selectedAppRoles, + selectedDashboardRoles, + ]; } diff --git a/lib/user_management/widgets/user_action_buttons.dart b/lib/user_management/widgets/user_action_buttons.dart index 6704e858..2363f427 100644 --- a/lib/user_management/widgets/user_action_buttons.dart +++ b/lib/user_management/widgets/user_action_buttons.dart @@ -2,7 +2,8 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart' show ContentActionButtons; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart' + show ContentActionButtons; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/bloc/user_management_bloc.dart'; import 'package:ui_kit/ui_kit.dart'; 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 29d8774f..e04fe798 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,7 +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/widgets/user_filter_dialog/user_filter_dialog.dart' show UserFilterDialog; +import 'package:flutter_news_app_web_dashboard_full_source_code/user_management/widgets/user_filter_dialog/user_filter_dialog.dart' + show UserFilterDialog; part 'user_filter_dialog_event.dart'; part 'user_filter_dialog_state.dart'; From bbcf16e3ba7bc993cf7791063bcafe112076e93c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:50:23 +0100 Subject: [PATCH 31/43] fix(navigation): correct router branch order Swaps the `StatefulShellBranch` for Content Management and User Management in `router.dart`. This aligns the router's branch indices with the visual order of the `NavigationDestination` widgets in the `AppShell`, fixing a bug where clicking a sidebar item would navigate to the wrong page. --- lib/router/router.dart | 68 +++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 6e8e9943..ec9238a6 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -173,40 +173,6 @@ GoRouter createRouter({ ), ], ), - StatefulShellBranch( - routes: [ - GoRoute( - path: Routes.userManagement, - name: Routes.userManagementName, - builder: (context, state) => const UserManagementPage(), - routes: [ - // Route for the UserFilterDialog. - GoRoute( - path: Routes.userFilterDialog, - name: Routes.userFilterDialogName, - pageBuilder: (context, state) { - final args = state.extra! as Map; - final userFilterState = - args['userFilterState'] as UserFilterState; - - return MaterialPage( - fullscreenDialog: true, - child: BlocProvider( - create: (providerContext) => - UserFilterDialogBloc()..add( - UserFilterDialogInitialized( - userFilterState: userFilterState, - ), - ), - child: const UserFilterDialog(), - ), - ); - }, - ), - ], - ), - ], - ), StatefulShellBranch( routes: [ GoRoute( @@ -331,6 +297,40 @@ GoRouter createRouter({ ), ], ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.userManagement, + name: Routes.userManagementName, + builder: (context, state) => const UserManagementPage(), + routes: [ + // Route for the UserFilterDialog. + GoRoute( + path: Routes.userFilterDialog, + name: Routes.userFilterDialogName, + pageBuilder: (context, state) { + final args = state.extra! as Map; + final userFilterState = + args['userFilterState'] as UserFilterState; + + return MaterialPage( + fullscreenDialog: true, + child: BlocProvider( + create: (providerContext) => + UserFilterDialogBloc()..add( + UserFilterDialogInitialized( + userFilterState: userFilterState, + ), + ), + child: const UserFilterDialog(), + ), + ); + }, + ), + ], + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( From 3621a6c8b739d59f48aa86a01bbb9b65ea237200 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 11:53:29 +0100 Subject: [PATCH 32/43] fix(user_management): refine user table UI and add derived columns Refactors the user table in `users_page.dart` to improve clarity and fix display issues. - Removes the "Dashboard Role" column as it is not relevant for all users. - Renames the "App Role" column to "Authentication" and adds a new "Subscription" column. - Implements logic to display derived, localized values for these new columns based on the user's `appRole`. - Truncates long email addresses with an ellipsis to prevent wrapping. - Updates filter logic to account for the removed dashboard role filter. --- lib/user_management/view/users_page.dart | 63 ++++++++++++++++++++---- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index d56bc89f..be08202b 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -3,6 +3,8 @@ 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/l10n/app_localizations.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/l10n/l10n.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,9 +43,7 @@ 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 || - state.selectedDashboardRoles.isNotEmpty; + return state.searchQuery.isNotEmpty || state.selectedAppRoles.isNotEmpty; } @override @@ -129,16 +129,16 @@ class _UsersPageState extends State { size: ColumnSize.L, ), DataColumn2( - label: Text(l10n.appRole), + label: Text(l10n.authentication), size: ColumnSize.S, ), DataColumn2( - label: Text(l10n.dashboardRole), + label: Text(l10n.subscription), size: ColumnSize.S, ), DataColumn2( label: Text(l10n.createdAt), - size: ColumnSize.S, + size: ColumnSize.M, ), DataColumn2( label: Text(l10n.actions), @@ -213,10 +213,23 @@ class _UsersDataSource extends DataTableSource { return DataRow2( // We don't implement onSelectChanged because user edits are handled // via the action buttons, not by navigating to a dedicated edit page. + // The email cell is wrapped in an Expanded widget to allow truncation. cells: [ - DataCell(Text(user.email)), - DataCell(Text(user.appRole.name)), - DataCell(Text(user.dashboardRole.name)), + DataCell( + Row( + children: [ + Expanded( + child: Text( + user.email, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + DataCell(Text(user.appRole.authenticationStatusL10n(context))), + DataCell(Text(user.appRole.subscriptionStatusL10n(context))), DataCell( Text( DateFormat('dd-MM-yyyy').format(user.createdAt.toLocal()), @@ -241,3 +254,35 @@ class _UsersDataSource extends DataTableSource { @override int get selectedRowCount => 0; } + +/// An extension to get the localized string for the authentication status +/// derived from [AppUserRole]. +extension AuthenticationStatusL10n on AppUserRole { + /// Returns the localized authentication status string. + String authenticationStatusL10n(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + switch (this) { + case AppUserRole.guestUser: + return l10n.authenticationAnonymous; + case AppUserRole.standardUser: + case AppUserRole.premiumUser: + return l10n.authenticationAuthenticated; + } + } +} + +/// 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; + } + } +} From ae959c8e7142cd5772b59ad66bc90e6bf4765d83 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 15:41:17 +0100 Subject: [PATCH 33/43] refactor(user_management): Align user action buttons with content actions - Refactored `UserActionButtons` to display a primary action icon directly in the table row. - Secondary actions (like "Copy User ID") are now in a `PopupMenuButton` (ellipsis icon). - This change mimics the UI pattern of `ContentActionButtons`, creating a consistent user experience across the dashboard. - The primary action is now an `IconButton` for "Promote" or "Demote", depending on the user's role. --- .../widgets/user_action_buttons.dart | 134 +++++++++--------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/lib/user_management/widgets/user_action_buttons.dart b/lib/user_management/widgets/user_action_buttons.dart index 2363f427..7ea77570 100644 --- a/lib/user_management/widgets/user_action_buttons.dart +++ b/lib/user_management/widgets/user_action_buttons.dart @@ -1,12 +1,9 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart' - show ContentActionButtons; +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/user_management/bloc/user_management_bloc.dart'; -import 'package:ui_kit/ui_kit.dart'; /// {@template user_action_buttons} /// A widget that displays contextual action buttons for a user in the user @@ -14,7 +11,8 @@ import 'package:ui_kit/ui_kit.dart'; /// /// Actions are presented in a [PopupMenuButton] and are conditionally /// displayed based on the user's role and permissions. This follows a similar -/// pattern to [ContentActionButtons] but is tailored for user management. +/// pattern to the content management action buttons but is tailored for user +/// management. /// {@endtemplate} class UserActionButtons extends StatelessWidget { /// {@macro user_action_buttons} @@ -32,95 +30,95 @@ class UserActionButtons extends StatelessWidget { @override Widget build(BuildContext context) { + // visibleActions holds the primary action icons that are always visible. + final visibleActions = []; + // overflowMenuItems holds actions that are in the popup menu. final overflowMenuItems = >[]; // Rule: Do not show any actions for admin users. if (user.dashboardRole == DashboardUserRole.admin) { return const SizedBox.shrink(); } - - // Add contextual actions based on the user's current dashboard role. + // Primary action: Copy User ID. This is always available for non-admins. + visibleActions.add( + IconButton( + visualDensity: VisualDensity.compact, + iconSize: 20, + icon: const Icon(Icons.copy), + tooltip: l10n.copyId, + onPressed: () => _onCopyId(context), + ), + ); + // Add contextual actions to the overflow menu based on the user's role. switch (user.dashboardRole) { case DashboardUserRole.none: overflowMenuItems.add( PopupMenuItem( value: 'promote', - child: Row( - children: [ - const Icon(Icons.arrow_upward), - const SizedBox(width: AppSpacing.sm), - Text(l10n.promoteToPublisher), - ], - ), + child: Text(l10n.promoteToPublisher), ), ); case DashboardUserRole.publisher: overflowMenuItems.add( PopupMenuItem( value: 'demote', - child: Row( - children: [ - const Icon(Icons.arrow_downward), - const SizedBox(width: AppSpacing.sm), - Text(l10n.demoteToUser), - ], - ), + child: Text(l10n.demoteToUser), ), ); case DashboardUserRole.admin: // No actions for admins, handled by the initial check. break; } - - // Add the "Copy User ID" action for all non-admin users. - overflowMenuItems.add( - PopupMenuItem( - value: 'copy_id', - child: Row( - children: [ - const Icon(Icons.copy), - const SizedBox(width: AppSpacing.sm), - Text(l10n.copyId), - ], + // Add the overflow menu button if there are items in it. + if (overflowMenuItems.isNotEmpty) { + visibleActions.add( + SizedBox( + width: 32, + child: PopupMenuButton( + iconSize: 20, + icon: const Icon(Icons.more_vert), + tooltip: l10n.moreActions, + onSelected: (value) { + switch (value) { + case 'promote': + _onPromote(context); + case 'demote': + _onDemote(context); + } + }, + itemBuilder: (context) => overflowMenuItems, + ), ), - ), - ); - + ); + } return Row( mainAxisSize: MainAxisSize.min, - children: [ - PopupMenuButton( - iconSize: 20, - icon: const Icon(Icons.more_vert), - tooltip: l10n.moreActions, - onSelected: (value) { - switch (value) { - case 'promote': - context.read().add( - UserDashboardRoleChanged( - userId: user.id, - dashboardRole: DashboardUserRole.publisher, - ), - ); - case 'demote': - context.read().add( - UserDashboardRoleChanged( - userId: user.id, - dashboardRole: DashboardUserRole.none, - ), - ); - case 'copy_id': - Clipboard.setData(ClipboardData(text: user.id)); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(l10n.idCopiedToClipboard(user.id))), - ); - } - }, - itemBuilder: (context) => overflowMenuItems, - ), - ], + children: visibleActions, ); } + + void _onPromote(BuildContext context) => + 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 _onCopyId(BuildContext context) { + Clipboard.setData(ClipboardData(text: user.id)); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.idCopiedToClipboard(user.id))), + ); + } } From 8b14f39be6a9ea249aad8af429597b93c8a2be3a Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 15:47:50 +0100 Subject: [PATCH 34/43] feat(l10n): add authentication and subscription status translations - Add new translations for authentication and subscription status in Arabic and English - Include column headers and status descriptions for both languages - Enhance filter dialog and data table translations --- lib/l10n/arb/app_ar.arb | 24 ++++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 5ea4b5c3..8a3204cc 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1987,5 +1987,29 @@ "selectDashboardRoles": "اختر أدوار لوحة التحكم", "@selectDashboardRoles": { "description": "نص تلميح لاختيار أدوار لوحة التحكم في مربع حوار التصفية." + }, + "authentication": "المصادقة", + "@authentication": { + "description": "رأس العمود لحالة المصادقة" + }, + "subscription": "الاشتراك", + "@subscription": { + "description": "رأس العمود لحالة الاشتراك" + }, + "authenticationAnonymous": "مجهول", + "@authenticationAnonymous": { + "description": "حالة المصادقة لمستخدم ضيف" + }, + "authenticationAuthenticated": "موثق", + "@authenticationAuthenticated": { + "description": "حالة المصادقة لمستخدم قام بتسجيل الدخول" + }, + "subscriptionFree": "مجاني", + "@subscriptionFree": { + "description": "حالة الاشتراك لمستخدم مجاني" + }, + "subscriptionPremium": "مميز", + "@subscriptionPremium": { + "description": "حالة الاشتراك لمستخدم مميز" } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a8373429..7f04050b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1983,5 +1983,29 @@ "selectDashboardRoles": "Select Dashboard Roles", "@selectDashboardRoles": { "description": "Hint text for selecting dashboard roles in a filter dialog." + }, + "authentication": "Authentication", + "@authentication": { + "description": "Column header for authentication status" + }, + "subscription": "Subscription", + "@subscription": { + "description": "Column header for subscription status" + }, + "authenticationAnonymous": "Anonymous", + "@authenticationAnonymous": { + "description": "Authentication status for a guest user" + }, + "authenticationAuthenticated": "Authenticated", + "@authenticationAuthenticated": { + "description": "Authentication status for a signed-in user" + }, + "subscriptionFree": "Free", + "@subscriptionFree": { + "description": "Subscription status for a free user" + }, + "subscriptionPremium": "Premium", + "@subscriptionPremium": { + "description": "Subscription status for a premium user" } } \ No newline at end of file From 57c774b3181e829cd620b13de7c6bf5e0ac220e3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 15:48:02 +0100 Subject: [PATCH 35/43] build: l10n --- lib/l10n/app_localizations.dart | 36 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 18 +++++++++++++++ lib/l10n/app_localizations_en.dart | 18 +++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bbf0e3b0..27bb3437 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2941,6 +2941,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Select Dashboard Roles'** String get selectDashboardRoles; + + /// Column header for authentication status + /// + /// In en, this message translates to: + /// **'Authentication'** + String get authentication; + + /// Column header for subscription status + /// + /// In en, this message translates to: + /// **'Subscription'** + String get subscription; + + /// Authentication status for a guest user + /// + /// In en, this message translates to: + /// **'Anonymous'** + String get authenticationAnonymous; + + /// Authentication status for a signed-in user + /// + /// In en, this message translates to: + /// **'Authenticated'** + String get authenticationAuthenticated; + + /// Subscription status for a free user + /// + /// In en, this message translates to: + /// **'Free'** + String get subscriptionFree; + + /// Subscription status for a premium user + /// + /// In en, this message translates to: + /// **'Premium'** + String get subscriptionPremium; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index a05e0bb8..76994143 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -1564,4 +1564,22 @@ class AppLocalizationsAr extends AppLocalizations { @override String get selectDashboardRoles => 'اختر أدوار لوحة التحكم'; + + @override + String get authentication => 'المصادقة'; + + @override + String get subscription => 'الاشتراك'; + + @override + String get authenticationAnonymous => 'مجهول'; + + @override + String get authenticationAuthenticated => 'موثق'; + + @override + String get subscriptionFree => 'مجاني'; + + @override + String get subscriptionPremium => 'مميز'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5e04f035..04325775 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1570,4 +1570,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectDashboardRoles => 'Select Dashboard Roles'; + + @override + String get authentication => 'Authentication'; + + @override + String get subscription => 'Subscription'; + + @override + String get authenticationAnonymous => 'Anonymous'; + + @override + String get authenticationAuthenticated => 'Authenticated'; + + @override + String get subscriptionFree => 'Free'; + + @override + String get subscriptionPremium => 'Premium'; } From 244d92cda9df797cf4b6ef8d0a163d472555e103 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 15:48:35 +0100 Subject: [PATCH 36/43] style: format --- lib/user_management/view/users_page.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index be08202b..085edc85 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -3,8 +3,6 @@ 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/l10n/app_localizations.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/l10n/l10n.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'; From 5a27a6f4af412704510fab99915cb006595ce5ff Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 15:55:37 +0100 Subject: [PATCH 37/43] style(user_management): adjust created at column size - Change ColumnSize.M to ColumnSize.S for the 'created at' column in the users page table --- lib/user_management/view/users_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index 085edc85..36d6dec7 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -136,7 +136,7 @@ class _UsersPageState extends State { ), DataColumn2( label: Text(l10n.createdAt), - size: ColumnSize.M, + size: ColumnSize.S, ), DataColumn2( label: Text(l10n.actions), From 3d0e8f1fdd1e2d1819aceb8935259dde3bbaad8e Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 31 Oct 2025 16:26:43 +0100 Subject: [PATCH 38/43] feat(user_management): add logging and improve error handling - Add logging statements to track user dashboard role changes - Implement try-catch block for better error handling - Include stack trace when logging errors - Use Logger instance for consistent logging throughout the bloc --- .../bloc/user_management_bloc.dart | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart index 5cf1a95b..d5e7cd54 100644 --- a/lib/user_management/bloc/user_management_bloc.dart +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; +import 'package:logging/logging.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; @@ -24,8 +25,10 @@ class UserManagementBloc UserManagementBloc({ required DataRepository usersRepository, required UserFilterBloc userFilterBloc, + Logger? logger, }) : _usersRepository = usersRepository, _userFilterBloc = userFilterBloc, + _logger = logger ?? Logger('UserManagementBloc'), super(const UserManagementState()) { on(_onLoadUsersRequested); on(_onUserDashboardRoleChanged); @@ -58,6 +61,7 @@ class UserManagementBloc final DataRepository _usersRepository; final UserFilterBloc _userFilterBloc; + final Logger _logger; late final StreamSubscription _filterSubscription; late final StreamSubscription _userUpdateSubscription; @@ -145,11 +149,31 @@ class UserManagementBloc UserDashboardRoleChanged event, Emitter emit, ) async { - final userToUpdate = state.users.firstWhere((u) => u.id == event.userId); - await _usersRepository.update( - id: event.userId, - item: userToUpdate.copyWith(dashboardRole: event.dashboardRole), + _logger.info( + 'Attempting to change dashboard role for user: ${event.userId} ' + 'to ${event.dashboardRole.name}', ); + try { + final userToUpdate = state.users.firstWhere((u) => u.id == event.userId); + _logger.info('Found user in state: $userToUpdate'); + + final updatedItem = userToUpdate.copyWith( + dashboardRole: event.dashboardRole, + ); + _logger.info('Sending updated user object to repository: $updatedItem'); + + await _usersRepository.update( + id: event.userId, + item: updatedItem, + ); + } catch (error, stackTrace) { + _logger.severe( + 'Error changing user dashboard role for ${event.userId}.', + error, + stackTrace, + ); + addError(error, stackTrace); + } } /// Handles the request to change a user's app role. From 7556d2f68ccd3d9efaf3bdc389731fd05d8e802b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 1 Nov 2025 08:43:57 +0100 Subject: [PATCH 39/43] feat(user_management): optimize users table for mobile view - Add LayoutBuilder to determine screen width - Implement conditional rendering of subscription column based on device type - Improve table styling and responsiveness --- lib/user_management/view/users_page.dart | 124 ++++++++++++----------- 1 file changed, 67 insertions(+), 57 deletions(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index 36d6dec7..e91c5a13 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -120,64 +120,71 @@ class _UsersPageState extends State { state.users.isNotEmpty) const LinearProgressIndicator(), Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.email), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.authentication), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.subscription), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.createdAt), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _UsersDataSource( - context: context, - users: state.users, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - // Handle pagination: fetch next page if needed. - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.users.length && - state.hasMore && - state.status != UserManagementStatus.loading) { - context.read().add( - LoadUsersRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - filter: context - .read() - .buildUsersFilterMap( - context.read().state, - ), + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.email), + size: ColumnSize.L, ), - ); - } + DataColumn2( + label: Text(l10n.authentication), + size: ColumnSize.S, + ), + if (!isMobile) + DataColumn2( + label: Text(l10n.subscription), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.createdAt), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _UsersDataSource( + context: context, + users: state.users, + hasMore: state.hasMore, + l10n: l10n, + isMobile: isMobile, + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + // Handle pagination: fetch next page if needed. + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.users.length && + state.hasMore && + state.status != UserManagementStatus.loading) { + context.read().add( + LoadUsersRequested( + startAfterId: state.cursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildUsersFilterMap( + context.read().state, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noUsersFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.sm, + horizontalMargin: AppSpacing.sm, + ); }, - empty: Center(child: Text(l10n.noUsersFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.sm, - horizontalMargin: AppSpacing.sm, ), ), ], @@ -195,12 +202,14 @@ class _UsersDataSource extends DataTableSource { required this.users, required this.hasMore, required this.l10n, + required this.isMobile, }); final BuildContext context; final List users; final bool hasMore; final AppLocalizations l10n; + final bool isMobile; @override DataRow? getRow(int index) { @@ -227,7 +236,8 @@ class _UsersDataSource extends DataTableSource { ), ), DataCell(Text(user.appRole.authenticationStatusL10n(context))), - DataCell(Text(user.appRole.subscriptionStatusL10n(context))), + if (!isMobile) + DataCell(Text(user.appRole.subscriptionStatusL10n(context))), DataCell( Text( DateFormat('dd-MM-yyyy').format(user.createdAt.toLocal()), From 59f855cbc3261d17e377063d540702c6829456a2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 1 Nov 2025 08:45:26 +0100 Subject: [PATCH 40/43] feat(user_management): conditionally display authentication column based on device type - Hide the 'authentication' column on mobile devices to improve UI/UX - Use a conditional statement to render the column only if the device is not mobile - Update both the DataColumn2 and DataCell related to the 'authentication' information --- lib/user_management/view/users_page.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/user_management/view/users_page.dart b/lib/user_management/view/users_page.dart index e91c5a13..b3490c3f 100644 --- a/lib/user_management/view/users_page.dart +++ b/lib/user_management/view/users_page.dart @@ -129,10 +129,11 @@ class _UsersPageState extends State { label: Text(l10n.email), size: ColumnSize.L, ), - DataColumn2( - label: Text(l10n.authentication), - size: ColumnSize.S, - ), + if (!isMobile) + DataColumn2( + label: Text(l10n.authentication), + size: ColumnSize.S, + ), if (!isMobile) DataColumn2( label: Text(l10n.subscription), @@ -235,7 +236,8 @@ class _UsersDataSource extends DataTableSource { ], ), ), - DataCell(Text(user.appRole.authenticationStatusL10n(context))), + if (!isMobile) + DataCell(Text(user.appRole.authenticationStatusL10n(context))), if (!isMobile) DataCell(Text(user.appRole.subscriptionStatusL10n(context))), DataCell( From a8592a48ceb6a559d0ed42f41973bccd769727fe Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 1 Nov 2025 09:01:31 +0100 Subject: [PATCH 41/43] refactor(user-management): add robust error handling to app role changes This commit enhances the _onUserAppRoleChanged method in the UserManagementBloc by introducing a try-catch block and detailed logging. This change mirrors the robust implementation of _onUserDashboardRoleChanged, ensuring that any errors during the update process are caught, logged, and handled gracefully, preventing the BLoC's event stream from crashing and improving the overall stability of the user management feature. --- .../bloc/user_management_bloc.dart | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/user_management/bloc/user_management_bloc.dart b/lib/user_management/bloc/user_management_bloc.dart index d5e7cd54..0ba3f473 100644 --- a/lib/user_management/bloc/user_management_bloc.dart +++ b/lib/user_management/bloc/user_management_bloc.dart @@ -181,10 +181,28 @@ class UserManagementBloc UserAppRoleChanged event, Emitter emit, ) async { - final userToUpdate = state.users.firstWhere((u) => u.id == event.userId); - await _usersRepository.update( - id: event.userId, - item: userToUpdate.copyWith(appRole: event.appRole), + _logger.info( + 'Attempting to change app role for user: ${event.userId} ' + 'to ${event.appRole.name}', ); + try { + final userToUpdate = state.users.firstWhere((u) => u.id == event.userId); + _logger.info('Found user in state: $userToUpdate'); + + final updatedItem = userToUpdate.copyWith(appRole: event.appRole); + _logger.info('Sending updated user object to repository: $updatedItem'); + + await _usersRepository.update( + id: event.userId, + item: updatedItem, + ); + } catch (error, stackTrace) { + _logger.severe( + 'Error changing user app role for ${event.userId}.', + error, + stackTrace, + ); + addError(error, stackTrace); + } } } From 3f6980d5326eeb936f7fb1c9984e9bb7ac1e074d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 1 Nov 2025 09:03:14 +0100 Subject: [PATCH 42/43] refactor(bootstrap): remove redundant client initializations This commit refactors the bootstrap.dart file to eliminate duplicated DataApi client initializations for the development and production environments. By merging the else if (development) block into the final else block, the code is made more concise and maintainable, as the client setup for both environments is now handled in a single location. --- lib/bootstrap.dart | 78 ---------------------------------------------- 1 file changed, 78 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 59430988..e311cf8b 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -132,84 +132,6 @@ Future bootstrap( // No initial data for users in demo mode. logger: Logger('DataInMemory'), ); - } else if (appConfig.environment == app_config.AppEnvironment.development) { - headlinesClient = DataApi( - httpClient: httpClient!, - modelName: 'headline', - fromJson: Headline.fromJson, - toJson: (headline) => headline.toJson(), - logger: Logger('DataApi'), - ); - topicsClient = DataApi( - httpClient: httpClient, - modelName: 'topic', - fromJson: Topic.fromJson, - toJson: (topic) => topic.toJson(), - logger: Logger('DataApi'), - ); - sourcesClient = DataApi( - httpClient: httpClient, - modelName: 'source', - fromJson: Source.fromJson, - toJson: (source) => source.toJson(), - logger: Logger('DataApi'), - ); - userContentPreferencesClient = DataApi( - httpClient: httpClient, - modelName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (prefs) => prefs.toJson(), - logger: Logger('DataApi'), - ); - userAppSettingsClient = DataApi( - httpClient: httpClient, - modelName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (settings) => settings.toJson(), - logger: Logger('DataApi'), - ); - remoteConfigClient = DataApi( - httpClient: httpClient, - modelName: 'remote_config', - fromJson: RemoteConfig.fromJson, - toJson: (config) => config.toJson(), - logger: Logger('DataApi'), - ); - dashboardSummaryClient = DataApi( - httpClient: httpClient, - modelName: 'dashboard_summary', - fromJson: DashboardSummary.fromJson, - toJson: (summary) => summary.toJson(), - logger: Logger('DataApi'), - ); - countriesClient = DataApi( - httpClient: httpClient, - modelName: 'country', - fromJson: Country.fromJson, - toJson: (country) => country.toJson(), - logger: Logger('DataApi'), - ); - languagesClient = DataApi( - httpClient: httpClient, - modelName: 'language', - fromJson: Language.fromJson, - toJson: (language) => language.toJson(), - logger: Logger('DataApi'), - ); - localAdsClient = DataApi( - httpClient: httpClient, - modelName: 'local_ad', - fromJson: LocalAd.fromJson, - toJson: LocalAd.toJson, - logger: Logger('DataApi'), - ); - usersClient = DataApi( - httpClient: httpClient, - modelName: 'user', - fromJson: User.fromJson, - toJson: (user) => user.toJson(), - logger: Logger('DataApi'), - ); } else { headlinesClient = DataApi( httpClient: httpClient!, From 550ab570141ed4090184dcfc111f01c0c343c726 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 1 Nov 2025 09:04:53 +0100 Subject: [PATCH 43/43] refactor(user-filter): remove unused status property from dialog state This commit cleans up the UserFilterDialogState by removing the selectedStatus property, which was an unused artifact and not applicable to user filtering. The constructor, copyWith method, props getter, and the reset event handler in UserFilterDialogBloc have all been updated to reflect this removal, resulting in a cleaner and more relevant state definition. --- .../user_filter_dialog/bloc/user_filter_dialog_state.dart | 7 ------- 1 file changed, 7 deletions(-) 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 45d5fa3c..7bc8edce 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,7 +10,6 @@ final class UserFilterDialogState extends Equatable { /// {@macro user_filter_dialog_state} const UserFilterDialogState({ this.searchQuery = '', - this.selectedStatus = ContentStatus.active, this.selectedAppRoles = const [], this.selectedDashboardRoles = const [], }); @@ -18,9 +17,6 @@ final class UserFilterDialogState extends Equatable { /// The current text in the search query field. final String searchQuery; - /// The single content status to be included in the filter. - final ContentStatus selectedStatus; - /// The list of app roles to be included in the filter. final List selectedAppRoles; @@ -30,13 +26,11 @@ final class UserFilterDialogState extends Equatable { /// Creates a copy of this [UserFilterDialogState] with updated values. UserFilterDialogState copyWith({ String? searchQuery, - ContentStatus? selectedStatus, List? selectedAppRoles, List? selectedDashboardRoles, }) { return UserFilterDialogState( searchQuery: searchQuery ?? this.searchQuery, - selectedStatus: selectedStatus ?? this.selectedStatus, selectedAppRoles: selectedAppRoles ?? this.selectedAppRoles, selectedDashboardRoles: selectedDashboardRoles ?? this.selectedDashboardRoles, @@ -46,7 +40,6 @@ final class UserFilterDialogState extends Equatable { @override List get props => [ searchQuery, - selectedStatus, selectedAppRoles, selectedDashboardRoles, ];