side menu issue fix and user role multi select done
This commit is contained in:
parent
1ea70bf93a
commit
fa310dd87c
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
29
lib/shared/providers/permissions_provider.dart
Normal file
29
lib/shared/providers/permissions_provider.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
lib/shared/widgets/can_permission.dart
Normal file
29
lib/shared/widgets/can_permission.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user