screen slide issue and add role

This commit is contained in:
Surendiran 2026-06-20 15:50:44 +05:30
parent 0102a7405c
commit 0d79d5a514
9 changed files with 276 additions and 58 deletions

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../roles/data/repositories/role_repository_impl.dart';
import '../../../roles/domain/usecases/role_usecases.dart';
@ -8,28 +9,64 @@ import '../../../roles/presentation/providers/roles_provider.dart';
class RoleFormState {
const RoleFormState({
this.editingRole,
this.permissionCatalog = const [],
this.isSubmitting = false,
this.errorMessage,
});
final RoleCardModel? editingRole;
final List<PermissionModuleCatalog> permissionCatalog;
final bool isSubmitting;
final String? errorMessage;
/// Modules that expose a view permission in the live catalog.
List<PermissionModuleCatalog> get selectableModules => permissionCatalog
.where((module) => module.permissionIdForAction('view') != null)
.toList();
RoleFormState copyWith({
RoleCardModel? editingRole,
List<PermissionModuleCatalog>? permissionCatalog,
bool? isSubmitting,
String? errorMessage,
bool clearError = false,
}) {
return RoleFormState(
editingRole: editingRole ?? this.editingRole,
permissionCatalog: permissionCatalog ?? this.permissionCatalog,
isSubmitting: isSubmitting ?? this.isSubmitting,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
);
}
}
CreateRoleRequest buildCreateRoleRequest({
required String name,
required String description,
required Map<String, bool> selectedModules,
required List<PermissionModuleCatalog> catalog,
}) {
final viewModules = <String>[];
final permissionIds = <int>[];
for (final module in catalog) {
if (selectedModules[module.code] != true) continue;
final viewPermissionId = module.permissionIdForAction('view');
if (viewPermissionId == null) continue;
viewModules.add(module.code);
permissionIds.add(viewPermissionId);
}
return CreateRoleRequest(
name: name,
description: description.isEmpty ? null : description,
permissionIds: permissionIds,
viewModules: viewModules,
);
}
final createRoleUseCaseProvider = Provider(
(ref) => CreateRoleUseCase(ref.watch(roleRepositoryProvider)),
);
@ -46,7 +83,13 @@ final roleFormProvider =
class RoleFormNotifier extends FamilyAsyncNotifier<RoleFormState, String?> {
@override
Future<RoleFormState> build(String? roleId) async {
if (roleId == null) return const RoleFormState();
if (roleId == null) {
final catalogResult = await ref.read(getPermissionCatalogUseCaseProvider)();
if (catalogResult.failure != null) throw catalogResult.failure!;
return RoleFormState(
permissionCatalog: catalogResult.data ?? const [],
);
}
final cached = ref.read(rolesListProvider).valueOrNull?.roles;
var editingRole = cached?.where((role) => role.id == roleId).firstOrNull;

View File

@ -3,12 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../domain/entities/rbac_entities.dart';
import '../providers/role_form_provider.dart';
import 'rbac_widgets.dart';
@ -27,10 +27,9 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final Map<String, bool> _viewPermissions = {
for (final module in rbacModules) module.key: false,
};
final Map<String, bool> _viewPermissions = {};
bool _prefilled = false;
bool _permissionsInitialized = false;
@override
void dispose() {
@ -49,6 +48,9 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final formState = ref.read(roleFormProvider(widget.roleId)).valueOrNull;
if (formState == null) return;
final notifier = ref.read(roleFormProvider(widget.roleId).notifier);
final bool success;
@ -62,9 +64,11 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
);
} else {
success = await notifier.submitCreate(
CreateRoleRequest(
buildCreateRoleRequest(
name: _nameController.text.trim(),
description: _descriptionController.text.trim(),
selectedModules: _viewPermissions,
catalog: formState.permissionCatalog,
),
);
}
@ -124,6 +128,20 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
onRetry: () => ref.invalidate(roleFormProvider(widget.roleId)),
),
data: (formState) {
if (!widget.isEditing &&
!_permissionsInitialized &&
formState.selectableModules.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _permissionsInitialized) return;
setState(() {
_permissionsInitialized = true;
for (final module in formState.selectableModules) {
_viewPermissions.putIfAbsent(module.code, () => false);
}
});
});
}
if (formState.editingRole != null && !_prefilled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _prefilled) return;
@ -167,16 +185,27 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
),
),
const SizedBox(height: 16),
...rbacModules.map((module) {
return ModulePermissionRow(
icon: module.icon,
color: module.color,
label: module.label,
value: _viewPermissions[module.key] ?? false,
onChanged: (v) =>
setState(() => _viewPermissions[module.key] = v),
);
}),
if (formState.selectableModules.isEmpty)
Text(
'No view permissions available from the server.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else
...formState.selectableModules.asMap().entries.map((entry) {
final module = entry.value;
final appearance =
permissionModuleAppearance(module.code, entry.key);
return ModulePermissionRow(
icon: appearance.icon,
color: appearance.color,
label: module.name,
value: _viewPermissions[module.code] ?? false,
onChanged: (v) =>
setState(() => _viewPermissions[module.code] = v),
);
}),
],
],
),

View File

@ -57,7 +57,8 @@ class RoleRemoteDataSource {
}
Future<RoleModel> createRole(CreateRoleRequest request) async {
final response = await dio.post(ApiEndpoints.roles, data: request.toJson());
final payload = request.toJson()..removeWhere((_, value) => value == null);
final response = await dio.post(ApiEndpoints.roles, data: payload);
return _parseRole(response.data);
}

View File

@ -10,6 +10,13 @@ const permissionMatrixActionOrder = [
'export',
];
class ModulePermissionEntry {
const ModulePermissionEntry({required this.id, required this.action});
final String id;
final String action;
}
String permissionActionLabel(String action) => switch (action) {
'view' => 'VIEW',
'create' => 'CREATE',
@ -32,25 +39,47 @@ class PermissionModuleCatalog {
required this.code,
required this.name,
required this.actions,
required this.permissions,
});
final String id;
final String code;
final String name;
final List<String> actions;
final List<ModulePermissionEntry> permissions;
int? permissionIdForAction(String action) {
final normalized = action.toLowerCase();
for (final permission in permissions) {
if (permission.action.toLowerCase() == normalized) {
return int.tryParse(permission.id);
}
}
return null;
}
factory PermissionModuleCatalog.fromJson(Map<String, dynamic> json) {
final permissions = json['permissions'] as List<dynamic>? ?? const [];
final actions = permissions
.map((item) => (item as Map<String, dynamic>)['action'] as String?)
.whereType<String>()
.toList();
final permissions = <ModulePermissionEntry>[];
for (final item in json['permissions'] as List<dynamic>? ?? const []) {
if (item is! Map<String, dynamic>) continue;
final action = item['action'] as String?;
if (action == null) continue;
permissions.add(
ModulePermissionEntry(
id: item['id']?.toString() ?? '',
action: action,
),
);
}
final actions = permissions.map((entry) => entry.action).toList();
return PermissionModuleCatalog(
id: json['id']?.toString() ?? '',
code: json['code'] as String? ?? '',
name: json['name'] as String? ?? '',
actions: sortPermissionActions(actions),
permissions: permissions,
);
}
}
@ -182,9 +211,9 @@ class RolePermissionMatrix {
'ROLES' => styles[1],
'MASTERS' => styles[2],
'VENDOR' => styles[3],
'PURCHASE_ORDERS' || 'PO' => styles[4],
'PURCHASE_ORDERS' || 'PURCHASE_ORDER' || 'PO' => styles[4],
'GRN' => styles[5],
'ASSETS' => styles[6],
'ASSETS' || 'ASSET' || 'ASSET_MANAGEMENT' => styles[6],
_ => styles[index % styles.length],
};
return byCode;

View File

@ -231,6 +231,8 @@ class CreateRoleRequest with _$CreateRoleRequest {
required String name,
String? description,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
@JsonKey(name: 'permission_ids') @Default([]) List<int> permissionIds,
@JsonKey(name: 'view_modules') @Default([]) List<String> viewModules,
}) = _CreateRoleRequest;
factory CreateRoleRequest.fromJson(Map<String, dynamic> json) =>

View File

@ -3011,6 +3011,10 @@ mixin _$CreateRoleRequest {
String? get description => throw _privateConstructorUsedError;
@JsonKey(name: 'is_active')
bool get isActive => throw _privateConstructorUsedError;
@JsonKey(name: 'permission_ids')
List<int> get permissionIds => throw _privateConstructorUsedError;
@JsonKey(name: 'view_modules')
List<String> get viewModules => throw _privateConstructorUsedError;
/// Serializes this CreateRoleRequest to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -3033,6 +3037,8 @@ abstract class $CreateRoleRequestCopyWith<$Res> {
String name,
String? description,
@JsonKey(name: 'is_active') bool isActive,
@JsonKey(name: 'permission_ids') List<int> permissionIds,
@JsonKey(name: 'view_modules') List<String> viewModules,
});
}
@ -3054,6 +3060,8 @@ class _$CreateRoleRequestCopyWithImpl<$Res, $Val extends CreateRoleRequest>
Object? name = null,
Object? description = freezed,
Object? isActive = null,
Object? permissionIds = null,
Object? viewModules = null,
}) {
return _then(
_value.copyWith(
@ -3069,6 +3077,14 @@ class _$CreateRoleRequestCopyWithImpl<$Res, $Val extends CreateRoleRequest>
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool,
permissionIds: null == permissionIds
? _value.permissionIds
: permissionIds // ignore: cast_nullable_to_non_nullable
as List<int>,
viewModules: null == viewModules
? _value.viewModules
: viewModules // ignore: cast_nullable_to_non_nullable
as List<String>,
)
as $Val,
);
@ -3088,6 +3104,8 @@ abstract class _$$CreateRoleRequestImplCopyWith<$Res>
String name,
String? description,
@JsonKey(name: 'is_active') bool isActive,
@JsonKey(name: 'permission_ids') List<int> permissionIds,
@JsonKey(name: 'view_modules') List<String> viewModules,
});
}
@ -3108,6 +3126,8 @@ class __$$CreateRoleRequestImplCopyWithImpl<$Res>
Object? name = null,
Object? description = freezed,
Object? isActive = null,
Object? permissionIds = null,
Object? viewModules = null,
}) {
return _then(
_$CreateRoleRequestImpl(
@ -3123,6 +3143,14 @@ class __$$CreateRoleRequestImplCopyWithImpl<$Res>
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool,
permissionIds: null == permissionIds
? _value._permissionIds
: permissionIds // ignore: cast_nullable_to_non_nullable
as List<int>,
viewModules: null == viewModules
? _value._viewModules
: viewModules // ignore: cast_nullable_to_non_nullable
as List<String>,
),
);
}
@ -3135,7 +3163,10 @@ class _$CreateRoleRequestImpl implements _CreateRoleRequest {
required this.name,
this.description,
@JsonKey(name: 'is_active') this.isActive = true,
});
@JsonKey(name: 'permission_ids') final List<int> permissionIds = const [],
@JsonKey(name: 'view_modules') final List<String> viewModules = const [],
}) : _permissionIds = permissionIds,
_viewModules = viewModules;
factory _$CreateRoleRequestImpl.fromJson(Map<String, dynamic> json) =>
_$$CreateRoleRequestImplFromJson(json);
@ -3147,10 +3178,27 @@ class _$CreateRoleRequestImpl implements _CreateRoleRequest {
@override
@JsonKey(name: 'is_active')
final bool isActive;
final List<int> _permissionIds;
@override
@JsonKey(name: 'permission_ids')
List<int> get permissionIds {
if (_permissionIds is EqualUnmodifiableListView) return _permissionIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_permissionIds);
}
final List<String> _viewModules;
@override
@JsonKey(name: 'view_modules')
List<String> get viewModules {
if (_viewModules is EqualUnmodifiableListView) return _viewModules;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_viewModules);
}
@override
String toString() {
return 'CreateRoleRequest(name: $name, description: $description, isActive: $isActive)';
return 'CreateRoleRequest(name: $name, description: $description, isActive: $isActive, permissionIds: $permissionIds, viewModules: $viewModules)';
}
@override
@ -3162,12 +3210,27 @@ class _$CreateRoleRequestImpl implements _CreateRoleRequest {
(identical(other.description, description) ||
other.description == description) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive));
other.isActive == isActive) &&
const DeepCollectionEquality().equals(
other._permissionIds,
_permissionIds,
) &&
const DeepCollectionEquality().equals(
other._viewModules,
_viewModules,
));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, name, description, isActive);
int get hashCode => Object.hash(
runtimeType,
name,
description,
isActive,
const DeepCollectionEquality().hash(_permissionIds),
const DeepCollectionEquality().hash(_viewModules),
);
/// Create a copy of CreateRoleRequest
/// with the given fields replaced by the non-null parameter values.
@ -3191,6 +3254,8 @@ abstract class _CreateRoleRequest implements CreateRoleRequest {
required final String name,
final String? description,
@JsonKey(name: 'is_active') final bool isActive,
@JsonKey(name: 'permission_ids') final List<int> permissionIds,
@JsonKey(name: 'view_modules') final List<String> viewModules,
}) = _$CreateRoleRequestImpl;
factory _CreateRoleRequest.fromJson(Map<String, dynamic> json) =
@ -3203,6 +3268,12 @@ abstract class _CreateRoleRequest implements CreateRoleRequest {
@override
@JsonKey(name: 'is_active')
bool get isActive;
@override
@JsonKey(name: 'permission_ids')
List<int> get permissionIds;
@override
@JsonKey(name: 'view_modules')
List<String> get viewModules;
/// Create a copy of CreateRoleRequest
/// with the given fields replaced by the non-null parameter values.

View File

@ -228,6 +228,16 @@ _$CreateRoleRequestImpl _$$CreateRoleRequestImplFromJson(
name: json['name'] as String,
description: json['description'] as String?,
isActive: json['is_active'] as bool? ?? true,
permissionIds:
(json['permission_ids'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const [],
viewModules:
(json['view_modules'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
);
Map<String, dynamic> _$$CreateRoleRequestImplToJson(
@ -236,6 +246,8 @@ Map<String, dynamic> _$$CreateRoleRequestImplToJson(
'name': instance.name,
'description': instance.description,
'is_active': instance.isActive,
'permission_ids': instance.permissionIds,
'view_modules': instance.viewModules,
};
_$UpdateRoleRequestImpl _$$UpdateRoleRequestImplFromJson(

View File

@ -118,11 +118,13 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [
GoRoute(
path: RouteConstants.dashboard,
builder: (context, state) => const DashboardScreen(),
pageBuilder: (context, state) =>
shellPage(state, const DashboardScreen()),
),
GoRoute(
path: RouteConstants.companies,
builder: (context, state) => const CompanyListScreen(),
pageBuilder: (context, state) =>
shellPage(state, const CompanyListScreen()),
routes: [
GoRoute(
path: 'add',
@ -137,7 +139,8 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: RouteConstants.branches,
builder: (context, state) => const BranchListScreen(),
pageBuilder: (context, state) =>
shellPage(state, const BranchListScreen()),
routes: [
GoRoute(
path: 'add',
@ -152,14 +155,18 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: RouteConstants.usersRoleManagement,
builder: (context, state) => UsersRoleManagementScreen(
initialTab: rbacTabFromLocation(state.uri.toString()),
pageBuilder: (context, state) => shellPage(
state,
UsersRoleManagementScreen(
initialTab: rbacTabFromLocation(state.uri.toString()),
),
),
),
GoRoute(
path: RouteConstants.users,
builder: (context, state) => const UsersRoleManagementScreen(
initialTab: RbacTab.users,
pageBuilder: (context, state) => shellPage(
state,
const UsersRoleManagementScreen(initialTab: RbacTab.users),
),
routes: [
GoRoute(
@ -180,8 +187,9 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: RouteConstants.roles,
builder: (context, state) => const UsersRoleManagementScreen(
initialTab: RbacTab.roles,
pageBuilder: (context, state) => shellPage(
state,
const UsersRoleManagementScreen(initialTab: RbacTab.roles),
),
routes: [
GoRoute(
@ -194,11 +202,13 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: RouteConstants.profile,
builder: (context, state) => const UserProfileScreen(),
pageBuilder: (context, state) =>
shellPage(state, const UserProfileScreen()),
),
GoRoute(
path: RouteConstants.assets,
builder: (context, state) => const AssetListScreen(),
pageBuilder: (context, state) =>
shellPage(state, const AssetListScreen()),
routes: [
GoRoute(
path: 'add',
@ -242,27 +252,33 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: RouteConstants.departments,
builder: (context, state) => const DepartmentsScreen(),
pageBuilder: (context, state) =>
shellPage(state, const DepartmentsScreen()),
),
GoRoute(
path: RouteConstants.locations,
builder: (context, state) => const LocationsScreen(),
pageBuilder: (context, state) =>
shellPage(state, const LocationsScreen()),
),
GoRoute(
path: RouteConstants.uom,
builder: (context, state) => const UomScreen(),
pageBuilder: (context, state) =>
shellPage(state, const UomScreen()),
),
GoRoute(
path: RouteConstants.reports,
builder: (context, state) => const ReportsScreen(),
pageBuilder: (context, state) =>
shellPage(state, const ReportsScreen()),
),
GoRoute(
path: RouteConstants.auditLogs,
builder: (context, state) => const AuditLogsScreen(),
pageBuilder: (context, state) =>
shellPage(state, const AuditLogsScreen()),
),
GoRoute(
path: RouteConstants.settings,
builder: (context, state) => const SettingsScreen(),
pageBuilder: (context, state) =>
shellPage(state, const SettingsScreen()),
routes: [
GoRoute(
path: 'general',
@ -301,7 +317,8 @@ final routerProvider = Provider<GoRouter>((ref) {
if (DevConfig.screenPreviewEnabled)
GoRoute(
path: RouteConstants.screenGallery,
builder: (context, state) => const ScreenGalleryScreen(),
pageBuilder: (context, state) =>
shellPage(state, const ScreenGalleryScreen()),
),
],
),
@ -319,6 +336,15 @@ Widget _themedRoute(GoRouterState state, Widget child) {
);
}
/// Instant swap for main shell pages prevents old page showing through
/// during sidebar navigation slide transitions.
Page<void> shellPage(GoRouterState state, Widget child) {
return NoTransitionPage<void>(
key: state.pageKey,
child: child,
);
}
class _AuthListenable extends ChangeNotifier {
_AuthListenable(this._ref) {
_ref.listen(authStateProvider, (_, __) => notifyListeners());

View File

@ -30,6 +30,20 @@ class _AppShellState extends ConsumerState<AppShell> {
final _scaffoldKey = GlobalKey<ScaffoldState>();
bool _sidebarCollapsed = false;
Widget _pageContent(BuildContext context, String currentRoute, Widget child) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
child: ThemeKeyedSubtree(
pageKey: currentRoute,
child: child,
),
),
);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authStateProvider);
@ -71,10 +85,7 @@ class _AppShellState extends ConsumerState<AppShell> {
_scaffoldKey.currentState?.closeDrawer();
},
),
body: ThemeKeyedSubtree(
pageKey: currentRoute,
child: widget.child,
),
body: _pageContent(context, currentRoute, widget.child),
);
}
@ -97,10 +108,7 @@ class _AppShellState extends ConsumerState<AppShell> {
onItemTap: (route) => goAndDismissOverlays(context, route),
),
Expanded(
child: ThemeKeyedSubtree(
pageKey: currentRoute,
child: widget.child,
),
child: _pageContent(context, currentRoute, widget.child),
),
],
),
@ -126,10 +134,7 @@ class _AppShellState extends ConsumerState<AppShell> {
),
const SizedBox(width: 12),
Expanded(
child: ThemeKeyedSubtree(
pageKey: currentRoute,
child: widget.child,
),
child: _pageContent(context, currentRoute, widget.child),
),
],
),