side menu issue fix and user role multi select done

This commit is contained in:
Surendiran 2026-06-22 15:24:35 +05:30
parent 1ea70bf93a
commit fa310dd87c
12 changed files with 766 additions and 167 deletions

View File

@ -1,31 +1,108 @@
import '../constants/enums.dart';
import '../errors/failure.dart';
import '../network/api_handler.dart';
import '../../core/constants/enums.dart';
/// Maps UI / route module keys to API permission module codes.
const Map<String, String> permissionModuleAliases = {
'master_data': 'MASTERS',
'masters': 'MASTERS',
'users': 'USERS',
'roles': 'ROLES',
'assets': 'ASSET',
'asset': 'ASSET',
'asset_categories': 'ASSET',
'asset_allocations': 'ASSET',
'asset_maintenance': 'ASSET',
'asset_disposal': 'ASSET',
'vendor': 'VENDOR',
'purchase_order': 'PURCHASE_ORDER',
'grn': 'GRN',
};
/// Modules that are always visible in navigation (no API permission key).
const Set<String> alwaysVisibleMenuModules = {'dashboard'};
String normalizePermissionModule(String module) {
final normalized = module.trim().toLowerCase().replaceAll('-', '_');
return permissionModuleAliases[normalized] ?? normalized.toUpperCase();
}
List<String> permissionActionAliases(PermissionAction action) {
return switch (action) {
PermissionAction.read => const ['read', 'view'],
PermissionAction.update => const ['update', 'edit'],
PermissionAction.create => const ['create'],
PermissionAction.delete => const ['delete'],
PermissionAction.export => const ['export'],
PermissionAction.approve => const ['approve'],
};
}
bool hasPermission({
required List<String> userPermissions,
required String module,
required PermissionAction action,
}) {
final normalizedModule = module.trim().toLowerCase();
final actionValue = action.value;
final candidates = {
'$normalizedModule.$actionValue',
'$normalizedModule:$actionValue',
'${normalizedModule.toUpperCase()}:$actionValue',
'${normalizedModule.toUpperCase()}:${actionValue.toUpperCase()}',
'${module.toUpperCase()}:$actionValue',
};
if (userPermissions.contains('*')) return true;
for (final key in candidates) {
final moduleKey = normalizePermissionModule(module);
final lowerModule = moduleKey.toLowerCase();
for (final actionAlias in permissionActionAliases(action)) {
final candidates = {
'$lowerModule.$actionAlias',
'$lowerModule:$actionAlias',
'$moduleKey:$actionAlias',
'$moduleKey:${actionAlias.toUpperCase()}',
'${module.trim().toUpperCase()}:$actionAlias',
'${module.trim().toUpperCase()}:${actionAlias.toUpperCase()}',
};
for (final key in candidates) {
if (userPermissions.contains(key)) return true;
}
}
final wildcards = {
'$lowerModule.*',
'$lowerModule:*',
'$moduleKey:*',
'${module.trim().toUpperCase()}:*',
};
for (final key in wildcards) {
if (userPermissions.contains(key)) return true;
}
return userPermissions.contains('$normalizedModule.*') ||
userPermissions.contains('${normalizedModule.toUpperCase()}:*');
return false;
}
bool canSeeMenuModule({
required List<String> permissions,
required String module,
required UserRole role,
}) {
if (alwaysVisibleMenuModules.contains(module)) return true;
if (isSuperAdmin(role) || permissions.contains('*')) return true;
if (module == 'users') {
return hasPermission(
userPermissions: permissions,
module: 'users',
action: PermissionAction.read,
) ||
hasPermission(
userPermissions: permissions,
module: 'roles',
action: PermissionAction.read,
);
}
return hasPermission(
userPermissions: permissions,
module: module,
action: PermissionAction.read,
);
}
bool isSuperAdmin(UserRole role) => role == UserRole.superAdmin;

View File

@ -32,7 +32,7 @@ class AuthRepositoryImpl implements AuthRepository {
return safeApiCall(() async {
final loginResponse = await remote.login(request);
await _persistSession(loginResponse);
final user = await remote.resolveCurrentUser(loginResponse.tokens.accessToken);
final user = await remote.getCurrentUser();
return loginResponse.copyWith(user: user);
});
}
@ -55,13 +55,7 @@ class AuthRepositoryImpl implements AuthRepository {
@override
Future<Result<UserModel>> getCurrentUser() async {
return safeApiCall(() async {
final accessToken = await tokenStorage.getAccessToken();
if (accessToken == null || accessToken.isEmpty) {
throw const FormatException('No access token');
}
return remote.resolveCurrentUser(accessToken);
});
return safeApiCall(() => remote.getCurrentUser());
}
@override
@ -90,7 +84,7 @@ class AuthRepositoryImpl implements AuthRepository {
return safeApiCall(() async {
final loginResponse = await remote.verifyOtp(request);
await _persistSession(loginResponse);
final user = await remote.resolveCurrentUser(loginResponse.tokens.accessToken);
final user = await remote.getCurrentUser();
return loginResponse.copyWith(user: user);
});
}

View File

@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_empty_state.dart';
@ -150,6 +152,10 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
final end = (page * pageSize).clamp(0, total);
final notifier = ref.read(masterListProvider(widget.masterId).notifier);
final canCreate = ref.can('masters', PermissionAction.create);
final canExport = ref.can('masters', PermissionAction.export);
final canEdit = ref.can('masters', PermissionAction.update);
final canDelete = ref.can('masters', PermissionAction.delete);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -164,11 +170,12 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
label: const Text('All Masters'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => _openFormPanel(),
icon: const Icon(Icons.add),
label: Text('Add ${def.title}'),
),
if (canCreate)
ElevatedButton.icon(
onPressed: () => _openFormPanel(),
icon: const Icon(Icons.add),
label: Text('Add ${def.title}'),
),
],
),
const SizedBox(height: 16),
@ -194,6 +201,7 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
searchController: _searchController,
searchHint: _searchHint(def),
isExporting: state.isExporting,
showExport: canExport,
onSearch: notifier.setSearch,
onExport: _exportRecords,
);
@ -223,6 +231,8 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
definition: def,
items: state.items,
isDeleting: state.isDeleting,
canEdit: canEdit,
canDelete: canDelete,
onEdit: (id) => _openFormPanel(recordId: id),
onDelete: _deleteRecord,
),
@ -303,6 +313,8 @@ class _MasterListTable extends StatelessWidget {
required this.definition,
required this.items,
required this.isDeleting,
required this.canEdit,
required this.canDelete,
required this.onEdit,
required this.onDelete,
});
@ -310,6 +322,8 @@ class _MasterListTable extends StatelessWidget {
final MasterDefinition definition;
final List<Map<String, dynamic>> items;
final bool isDeleting;
final bool canEdit;
final bool canDelete;
final ValueChanged<String> onEdit;
final ValueChanged<Map<String, dynamic>> onDelete;
@ -444,20 +458,22 @@ class _MasterListTable extends StatelessWidget {
actionsCell: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: id == null ? null : () => onEdit(id),
),
IconButton(
tooltip: 'Delete',
icon: Icon(
Icons.delete_outline,
size: 20,
color: theme.colorScheme.error,
if (canEdit)
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined, size: 20),
onPressed: id == null ? null : () => onEdit(id),
),
if (canDelete)
IconButton(
tooltip: 'Delete',
icon: Icon(
Icons.delete_outline,
size: 20,
color: theme.colorScheme.error,
),
onPressed: isDeleting ? null : () => onDelete(row),
),
onPressed: isDeleting ? null : () => onDelete(row),
),
],
),
);

View File

@ -1,19 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_hover_effect.dart';
import '../../domain/entities/master_definition.dart';
class MastersHubScreen extends StatelessWidget {
class MastersHubScreen extends ConsumerWidget {
const MastersHubScreen({super.key});
static const double _tileMaxWidth = 120;
static const double _tileHeight = 92;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final categories = masterCategories;
final canViewMasters = ref.can('masters', PermissionAction.read);
return LayoutBuilder(
builder: (context, constraints) {
@ -37,6 +41,7 @@ class MastersHubScreen extends StatelessWidget {
),
const SizedBox(height: 24),
...categories.map((category) {
if (!canViewMasters) return const SizedBox.shrink();
final items = masterDefinitions
.where((def) => def.category == category)
.toList();

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/theme/theme_provider.dart';
@ -9,6 +10,7 @@ import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/utils/file_download_helper.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_card.dart';
@ -142,6 +144,11 @@ class _UsersRoleManagementScreenState
summary?.rolesCount ??
state.roles.length;
final isCompact = context.isMobile || MediaQuery.sizeOf(context).width < 900;
final canCreateUser = ref.can('users', PermissionAction.create);
final canCreateRole = ref.can('roles', PermissionAction.create);
final canViewUsers = ref.can('users', PermissionAction.read);
final canViewRoles = ref.can('roles', PermissionAction.read);
final canEditRoles = ref.can('roles', PermissionAction.update);
return Padding(
padding: EdgeInsets.all(isCompact ? 16 : 24),
@ -150,6 +157,8 @@ class _UsersRoleManagementScreenState
children: [
_Header(
compact: isCompact,
canCreateRole: canCreateRole,
canCreateUser: canCreateUser,
onNewRole: _openCreateRole,
onAddUser: _openAddUser,
),
@ -217,26 +226,32 @@ class _UsersRoleManagementScreenState
state: state,
userCount: summary?.totalUsers ?? usersState?.total ?? state.totalUsers,
roleCount: roleCount,
canViewUsers: canViewUsers,
canViewRoles: canViewRoles,
canEditRoles: canEditRoles,
),
const SizedBox(height: 16),
Expanded(
child: IndexedStack(
index: switch (state.selectedTab) {
RbacTab.users => 0,
RbacTab.roles => 1,
RbacTab.permissions => 2,
},
index: _tabIndex(
state.selectedTab,
canViewUsers: canViewUsers,
canViewRoles: canViewRoles,
canEditRoles: canEditRoles,
),
children: [
_UsersTab(
onAddUser: _openAddUser,
onEditUser: (user) => _openUserPanel(userId: user.id),
),
_RolesTab(
onNewRole: _openCreateRole,
onEditRole: _editRole,
onDeleteRole: _deleteRole,
),
const _PermissionMatrixTab(),
if (canViewUsers)
_UsersTab(
onAddUser: _openAddUser,
onEditUser: (user) => _openUserPanel(userId: user.id),
),
if (canViewRoles)
_RolesTab(
onNewRole: _openCreateRole,
onEditRole: _editRole,
onDeleteRole: _deleteRole,
),
if (canEditRoles) const _PermissionMatrixTab(),
],
),
),
@ -246,14 +261,33 @@ class _UsersRoleManagementScreenState
}
}
int _tabIndex(
RbacTab tab, {
required bool canViewUsers,
required bool canViewRoles,
required bool canEditRoles,
}) {
final tabs = <RbacTab>[
if (canViewUsers) RbacTab.users,
if (canViewRoles) RbacTab.roles,
if (canEditRoles) RbacTab.permissions,
];
final index = tabs.indexOf(tab);
return index < 0 ? 0 : index;
}
class _Header extends StatelessWidget {
const _Header({
required this.compact,
required this.canCreateRole,
required this.canCreateUser,
required this.onNewRole,
required this.onAddUser,
});
final bool compact;
final bool canCreateRole;
final bool canCreateUser;
final VoidCallback onNewRole;
final VoidCallback onAddUser;
@ -282,16 +316,18 @@ class _Header extends StatelessWidget {
spacing: 12,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: onNewRole,
icon: const Icon(Icons.add_circle_outline, size: 18),
label: const Text('New Role'),
),
ElevatedButton.icon(
onPressed: onAddUser,
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Add User'),
),
if (canCreateRole)
OutlinedButton.icon(
onPressed: onNewRole,
icon: const Icon(Icons.add_circle_outline, size: 18),
label: const Text('New Role'),
),
if (canCreateUser)
ElevatedButton.icon(
onPressed: onAddUser,
icon: const Icon(Icons.person_add_outlined, size: 18),
label: const Text('Add User'),
),
],
);
@ -317,11 +353,17 @@ class _TabBar extends ConsumerWidget {
required this.state,
required this.userCount,
required this.roleCount,
required this.canViewUsers,
required this.canViewRoles,
required this.canEditRoles,
});
final RbacState state;
final int userCount;
final int roleCount;
final bool canViewUsers;
final bool canViewRoles;
final bool canEditRoles;
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -329,25 +371,28 @@ class _TabBar extends ConsumerWidget {
scrollDirection: Axis.horizontal,
child: Row(
children: [
_TabButton(
label: 'Users ($userCount)',
icon: Icons.people_outline,
selected: state.selectedTab == RbacTab.users,
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users),
),
_TabButton(
label: 'Roles ($roleCount)',
icon: Icons.shield_outlined,
selected: state.selectedTab == RbacTab.roles,
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles),
),
_TabButton(
label: 'Permission Matrix',
icon: Icons.vpn_key_outlined,
selected: state.selectedTab == RbacTab.permissions,
onTap: () =>
ref.read(rbacProvider.notifier).setTab(RbacTab.permissions),
),
if (canViewUsers)
_TabButton(
label: 'Users ($userCount)',
icon: Icons.people_outline,
selected: state.selectedTab == RbacTab.users,
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.users),
),
if (canViewRoles)
_TabButton(
label: 'Roles ($roleCount)',
icon: Icons.shield_outlined,
selected: state.selectedTab == RbacTab.roles,
onTap: () => ref.read(rbacProvider.notifier).setTab(RbacTab.roles),
),
if (canEditRoles)
_TabButton(
label: 'Permission Matrix',
icon: Icons.vpn_key_outlined,
selected: state.selectedTab == RbacTab.permissions,
onTap: () =>
ref.read(rbacProvider.notifier).setTab(RbacTab.permissions),
),
],
),
);
@ -596,6 +641,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
),
data: (usersState) {
final filters = usersState.filters;
final canExport = ref.can('users', PermissionAction.export);
final canEditUser = ref.can('users', PermissionAction.update);
final canDeleteUser = ref.can('users', PermissionAction.delete);
final roles = ['All roles', ...?filters?.roles.map((r) => r.name)];
final departments = [
'All departments',
@ -640,6 +688,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
departments: departments,
statuses: statuses,
isExporting: usersState.isExporting,
showExport: canExport,
onExport: _exportUsers,
onSearch: ref.read(usersListProvider.notifier).setSearch,
onRoleChanged: (value) {
@ -758,6 +807,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
UserTableActionsCell(
child: UserTableActions(
user: user,
canEdit: canEditUser,
canResetPassword: canEditUser,
canDeactivate: canDeleteUser,
onEdit: () => _editUser(user),
onResetPassword: () =>
_resetPassword(user),
@ -854,6 +906,7 @@ class _UsersFilterBar extends StatelessWidget {
required this.onSearch,
required this.onExport,
this.isExporting = false,
this.showExport = true,
required this.onRoleChanged,
required this.onDepartmentChanged,
required this.onStatusChanged,
@ -869,6 +922,7 @@ class _UsersFilterBar extends StatelessWidget {
final ValueChanged<String> onSearch;
final VoidCallback onExport;
final bool isExporting;
final bool showExport;
final ValueChanged<String> onRoleChanged;
final ValueChanged<String> onDepartmentChanged;
final ValueChanged<String> onStatusChanged;
@ -916,13 +970,18 @@ class _UsersFilterBar extends StatelessWidget {
),
];
final visibleFilters = [
...filters.take(3),
if (showExport) filters[3],
];
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: filters),
Wrap(spacing: 12, runSpacing: 12, children: visibleFilters),
],
);
}
@ -936,8 +995,10 @@ class _UsersFilterBar extends StatelessWidget {
Expanded(flex: 2, child: filters[1]),
const SizedBox(width: 12),
Expanded(child: filters[2]),
const SizedBox(width: 12),
filters[3],
if (showExport) ...[
const SizedBox(width: 12),
filters[3],
],
],
);
}
@ -987,6 +1048,9 @@ class _RolesTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final rolesAsync = ref.watch(rolesListProvider);
final canCreateRole = ref.can('roles', PermissionAction.create);
final canEditRole = ref.can('roles', PermissionAction.update);
final canDeleteRole = ref.can('roles', PermissionAction.delete);
// Text styles are resolved at build time; rebuild cards when theme toggles.
final themeMode = ref.watch(themeModeProvider);
final brightness = Theme.of(context).brightness;
@ -1014,9 +1078,9 @@ class _RolesTab extends ConsumerWidget {
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: roles.length + 1,
itemCount: roles.length + (canCreateRole ? 1 : 0),
itemBuilder: (context, index) {
if (index == roles.length) {
if (canCreateRole && index == roles.length) {
return AppHoverEffect(
onTap: onNewRole,
showHoverBorder: false,
@ -1069,12 +1133,13 @@ class _RolesTab extends ConsumerWidget {
child: Icon(appearance.icon, color: appearance.color, size: 20),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => onEditRole(role),
visualDensity: VisualDensity.compact,
),
if (!isProtectedRole(role))
if (canEditRole)
IconButton(
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => onEditRole(role),
visualDensity: VisualDensity.compact,
),
if (canDeleteRole && !isProtectedRole(role))
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () => onDeleteRole(role),
@ -1202,6 +1267,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
Widget build(BuildContext context) {
final rolesAsync = ref.watch(rolesListProvider);
final catalogAsync = ref.watch(permissionCatalogProvider);
final canEditRoles = ref.can('roles', PermissionAction.update);
final selectedRoleId = _selectedRoleId;
final matrixAsync = selectedRoleId == null
? null
@ -1272,22 +1338,23 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
],
),
),
ElevatedButton.icon(
onPressed: selectedRoleId == null || _isSaving
? null
: () => _save(selectedRole?.name ?? 'role'),
icon: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save_outlined, size: 18),
label: Text(_isSaving ? 'Saving...' : 'Save changes'),
),
if (canEditRoles)
ElevatedButton.icon(
onPressed: selectedRoleId == null || _isSaving
? null
: () => _save(selectedRole?.name ?? 'role'),
icon: _isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save_outlined, size: 18),
label: Text(_isSaving ? 'Saving...' : 'Save changes'),
),
],
),
),

View File

@ -192,6 +192,9 @@ class UserTableActions extends StatelessWidget {
required this.onEdit,
required this.onResetPassword,
required this.onDeactivate,
this.canEdit = true,
this.canResetPassword = true,
this.canDeactivate = true,
});
static const columnWidth = 120.0;
@ -200,6 +203,9 @@ class UserTableActions extends StatelessWidget {
final VoidCallback onEdit;
final VoidCallback onResetPassword;
final VoidCallback onDeactivate;
final bool canEdit;
final bool canResetPassword;
final bool canDeactivate;
@override
Widget build(BuildContext context) {
@ -209,24 +215,27 @@ class UserTableActions extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
_UserActionIcon(
tooltip: 'Edit user',
icon: Icons.edit_outlined,
color: muted,
onPressed: onEdit,
),
_UserActionIcon(
tooltip: 'Reset password',
icon: Icons.vpn_key_outlined,
color: muted,
onPressed: onResetPassword,
),
_UserActionIcon(
tooltip: 'Deactivate user',
icon: Icons.person_off_outlined,
color: muted,
onPressed: onDeactivate,
),
if (canEdit)
_UserActionIcon(
tooltip: 'Edit user',
icon: Icons.edit_outlined,
color: muted,
onPressed: onEdit,
),
if (canResetPassword)
_UserActionIcon(
tooltip: 'Reset password',
icon: Icons.vpn_key_outlined,
color: muted,
onPressed: onResetPassword,
),
if (canDeactivate)
_UserActionIcon(
tooltip: 'Deactivate user',
icon: Icons.person_off_outlined,
color: muted,
onPressed: onDeactivate,
),
],
);
}

View File

@ -28,10 +28,14 @@ class UserModel with _$UserModel {
/// Parses user objects returned by auth endpoints (`/auth/login`, `/auth/me`).
factory UserModel.fromLoginJson(Map<String, dynamic> json) {
final role = json['role_name'] ??
final roleRaw = json['role_name'] ??
json['role'] ??
json['role_slug'] ??
_firstRoleFromList(json['roles']) ??
'employee';
final role = roleRaw is Map
? (roleRaw['name'] as String? ?? roleRaw['slug'] as String? ?? 'employee')
: roleRaw.toString();
final name = json['full_name'] ??
json['name'] ??
[
@ -41,12 +45,13 @@ class UserModel with _$UserModel {
return UserModel(
id: json['id']?.toString() ?? '',
employeeId: (json['employee_code'] ?? json['employee_id'] ?? '') as String,
employeeId: (json['employee_code'] ?? json['employee_id'] ?? '').toString(),
name: name is String && name.isNotEmpty ? name : (json['email'] as String? ?? ''),
email: json['email'] as String? ?? '',
mobile: json['mobile']?.toString() ?? '',
role: role is String ? role : role.toString(),
department: json['department_name'] as String? ?? json['department'] as String?,
role: role,
department: _nestedLabel(json['department_name']) ??
_nestedLabel(json['department']),
status: json['status'] as String? ?? 'active',
companyId: json['company_id']?.toString(),
branchId: json['branch_id']?.toString(),
@ -56,15 +61,32 @@ class UserModel with _$UserModel {
.toList() ??
const [],
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
? DateTime.tryParse(json['created_at'].toString())
: null,
updatedAt: json['updated_at'] != null
? DateTime.tryParse(json['updated_at'] as String)
? DateTime.tryParse(json['updated_at'].toString())
: null,
);
}
}
Object? _firstRoleFromList(Object? roles) {
if (roles is! List || roles.isEmpty) return null;
return roles.first;
}
String? _nestedLabel(Object? value) {
if (value == null) return null;
if (value is String) return value.isEmpty ? null : value;
if (value is Map) {
final name = value['name'];
if (name is String && name.isNotEmpty) return name;
final code = value['code'];
if (code is String && code.isNotEmpty) return code;
}
return null;
}
extension UserModelX on UserModel {
UserRole get userRole => UserRole.fromValue(role);
EntityStatus get entityStatus => EntityStatus.fromValue(status);

View File

@ -0,0 +1,29 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/enums.dart';
import '../../core/utils/permission_utils.dart';
import 'auth_provider.dart';
final userPermissionsProvider = Provider<List<String>>((ref) {
return ref.watch(authStateProvider).user?.permissions ?? const [];
});
extension PermissionCheck on WidgetRef {
bool can(String module, PermissionAction action) {
return hasPermission(
userPermissions: read(userPermissionsProvider),
module: module,
action: action,
);
}
}
extension PermissionCheckReader on Ref {
bool can(String module, PermissionAction action) {
return hasPermission(
userPermissions: read(userPermissionsProvider),
module: module,
action: action,
);
}
}

View File

@ -132,14 +132,47 @@ List<MenuItem> getVisibleMenuItems({
return appMenuItems;
}
return appMenuItems.where((item) {
final visible = <MenuItem>[];
for (final item in appMenuItems) {
if (item.requiredRole != null && !isSuperAdmin(role) && role != item.requiredRole) {
return false;
continue;
}
return hasPermission(
userPermissions: permissions,
if (item.children.isNotEmpty) {
final visibleChildren = item.children
.where(
(child) => canSeeMenuModule(
permissions: permissions,
module: child.module,
role: role,
),
)
.toList();
if (visibleChildren.isEmpty) continue;
visible.add(
MenuItem(
label: item.label,
icon: item.icon,
route: visibleChildren.first.route,
module: item.module,
children: visibleChildren,
requiredRole: item.requiredRole,
),
);
continue;
}
if (!canSeeMenuModule(
permissions: permissions,
module: item.module,
action: PermissionAction.read,
) || isAdmin(role);
}).toList();
role: role,
)) {
continue;
}
visible.add(item);
}
return visible;
}

View File

@ -11,6 +11,7 @@ class AppSearchExportBar extends StatelessWidget {
this.wrapped = false,
this.searchController,
this.searchWidth = 320,
this.showExport = true,
});
final String searchHint;
@ -20,6 +21,7 @@ class AppSearchExportBar extends StatelessWidget {
final bool wrapped;
final TextEditingController? searchController;
final double searchWidth;
final bool showExport;
@override
Widget build(BuildContext context) {
@ -50,13 +52,15 @@ class AppSearchExportBar extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
exportButton,
],
),
if (showExport) ...[
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
exportButton,
],
),
],
],
);
}
@ -65,7 +69,7 @@ class AppSearchExportBar extends StatelessWidget {
children: [
SizedBox(width: searchWidth, child: searchField),
const Spacer(),
exportButton,
if (showExport) exportButton,
],
);
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@ -93,7 +95,8 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
(themeMode == ThemeModeOption.system && !isDark);
return AnimatedContainer(
duration: AppConstants.animationDuration,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
width: widget.isDrawer
? double.infinity
: (widget.collapsed ? _sidebarCollapsedWidth : _sidebarExpandedWidth),
@ -141,6 +144,15 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
else
const SizedBox(height: 4),
..._mainMenuItems.map((item) {
if (isNarrow && item.children.isNotEmpty) {
return _CollapsedFlyoutNavItem(
item: item,
selected: _isGroupActive(item),
isChildSelected: _isSelected,
onChildTap: widget.onItemTap,
);
}
if (item.children.isNotEmpty && !isNarrow) {
final expanded = _isGroupExpanded(item);
final active = _isGroupActive(item);
@ -178,10 +190,6 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
(isNarrow && _isGroupActive(item)),
collapsed: isNarrow,
onTap: () {
if (item.children.isNotEmpty && isNarrow) {
widget.onItemTap(item.children.first.route);
return;
}
widget.onItemTap(item.route);
},
);
@ -330,7 +338,11 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
return Padding(
padding: const EdgeInsets.all(12),
child: Center(
child: _UserAvatar(name: name),
child: _UserProfileMenu(
userName: name,
menuOffset: const Offset(-8, -210),
child: _UserAvatar(name: name),
),
),
);
}
@ -378,6 +390,296 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
}
}
class _CollapsedFlyoutNavItem extends StatefulWidget {
const _CollapsedFlyoutNavItem({
required this.item,
required this.selected,
required this.isChildSelected,
required this.onChildTap,
});
final menu.MenuItem item;
final bool selected;
final bool Function(String route) isChildSelected;
final void Function(String route) onChildTap;
@override
State<_CollapsedFlyoutNavItem> createState() =>
_CollapsedFlyoutNavItemState();
}
class _CollapsedFlyoutNavItemState extends State<_CollapsedFlyoutNavItem> {
final _anchorKey = GlobalKey();
OverlayEntry? _overlayEntry;
Timer? _hideTimer;
@override
void dispose() {
_hideTimer?.cancel();
_removeOverlay();
super.dispose();
}
void _cancelHide() {
_hideTimer?.cancel();
_hideTimer = null;
}
void _scheduleHide() {
_cancelHide();
_hideTimer = Timer(const Duration(milliseconds: 50), () {
if (!mounted) return;
_removeOverlay();
});
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
if (mounted) setState(() {});
}
void _showOverlay() {
if (_overlayEntry != null) return;
_insertOverlay();
if (_overlayEntry == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _overlayEntry != null) return;
_insertOverlay();
});
}
}
void _insertOverlay() {
if (_overlayEntry != null) return;
final renderBox =
_anchorKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final anchorTopLeft = renderBox.localToGlobal(Offset.zero);
final anchorSize = renderBox.size;
const panelWidth = 220.0;
_overlayEntry = OverlayEntry(
builder: (overlayContext) {
final theme = Theme.of(overlayContext);
final primary = theme.colorScheme.primary;
return Positioned(
left: anchorTopLeft.dx,
top: anchorTopLeft.dy,
child: TapRegion(
onTapOutside: (_) => _removeOverlay(),
child: MouseRegion(
onEnter: (_) => _cancelHide(),
onExit: (_) => _scheduleHide(),
child: _SidebarFlyoutFade(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: anchorSize.width,
height: anchorSize.height,
),
Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: theme.colorScheme.surface,
shadowColor: Colors.black45,
child: SizedBox(
width: panelWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
child: Text(
widget.item.label,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.4,
),
),
),
const Divider(height: 1),
...widget.item.children.map((child) {
final childSelected =
widget.isChildSelected(child.route);
return InkWell(
onTap: () {
_removeOverlay();
widget.onChildTap(child.route);
},
child: Container(
height: _sidebarItemHeight,
padding: const EdgeInsets.symmetric(
horizontal: _sidebarItemPadding,
),
color: childSelected
? primary.withValues(alpha: 0.1)
: null,
child: Row(
children: [
Icon(
child.icon,
size: _sidebarIconSize,
color: childSelected
? primary
: theme
.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
child.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(
color: childSelected
? primary
: theme.colorScheme
.onSurfaceVariant,
fontWeight: childSelected
? FontWeight.w600
: FontWeight.w500,
),
),
),
],
),
),
);
}),
],
),
),
),
],
),
),
),
),
);
},
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final primary = theme.colorScheme.primary;
return MouseRegion(
onEnter: (_) {
_cancelHide();
_showOverlay();
},
onExit: (_) {
if (_overlayEntry != null) return;
_scheduleHide();
},
child: KeyedSubtree(
key: _anchorKey,
child: Padding(
padding: const EdgeInsets.only(bottom: 4),
child: SizedBox(
width: double.infinity,
child: InkWell(
onTap: _showOverlay,
borderRadius: BorderRadius.circular(12),
child: Container(
height: _sidebarItemHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: widget.selected || _overlayEntry != null
? primary.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.item.icon,
size: 22,
color: widget.selected || _overlayEntry != null
? primary
: theme.colorScheme.onSurfaceVariant,
),
Icon(
Icons.chevron_right,
size: 12,
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7),
),
],
),
),
),
),
),
),
);
}
}
class _SidebarFlyoutFade extends StatefulWidget {
const _SidebarFlyoutFade({required this.child});
final Widget child;
@override
State<_SidebarFlyoutFade> createState() => _SidebarFlyoutFadeState();
}
class _SidebarFlyoutFadeState extends State<_SidebarFlyoutFade>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fade;
late final Animation<Offset> _slide;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 120),
);
final curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
_fade = curve;
_slide = Tween<Offset>(
begin: const Offset(-0.04, 0),
end: Offset.zero,
).animate(curve);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fade,
child: SlideTransition(
position: _slide,
child: widget.child,
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label});
@ -437,7 +739,7 @@ class _SidebarNavItem extends StatelessWidget {
width: double.infinity,
child: Tooltip(
message: label,
waitDuration: const Duration(milliseconds: 500),
waitDuration: const Duration(milliseconds: 250),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
@ -589,19 +891,25 @@ class _UserAvatar extends StatelessWidget {
}
class _UserProfileMenu extends ConsumerWidget {
const _UserProfileMenu({this.userName});
const _UserProfileMenu({
this.userName,
this.child,
this.menuOffset,
});
final String? userName;
final Widget? child;
final Offset? menuOffset;
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
tooltip: userName ?? 'Account menu',
padding: EdgeInsets.zero,
offset: menuOffset ?? Offset.zero,
position: child != null
? PopupMenuPosition.over
: PopupMenuPosition.under,
onSelected: (value) async {
switch (value) {
case 'profile':
@ -622,6 +930,12 @@ class _UserProfileMenu extends ConsumerWidget {
const PopupMenuDivider(),
const PopupMenuItem(value: 'logout', child: Text('Logout')),
],
child: child ??
Icon(
Icons.more_vert,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants/enums.dart';
import '../providers/permissions_provider.dart';
/// Shows [child] only when the logged-in user has the given permission.
class CanPermission extends ConsumerWidget {
const CanPermission({
super.key,
required this.module,
required this.action,
required this.child,
this.fallback,
});
final String module;
final PermissionAction action;
final Widget child;
final Widget? fallback;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (ref.can(module, action)) {
return child;
}
return fallback ?? const SizedBox.shrink();
}
}