user & roles done
This commit is contained in:
parent
9a9f944b26
commit
0102a7405c
@ -21,6 +21,7 @@ class BharatErpApp extends ConsumerWidget {
|
||||
theme: buildLightTheme(branding),
|
||||
darkTheme: buildDarkTheme(branding),
|
||||
themeMode: resolveThemeMode(themeMode),
|
||||
themeAnimationDuration: Duration.zero,
|
||||
routerConfig: router,
|
||||
builder: (context, child) => ResponsiveBreakpoints.builder(
|
||||
child: child!,
|
||||
|
||||
@ -3,18 +3,22 @@ import 'package:intl/intl.dart';
|
||||
class DateFormatter {
|
||||
DateFormatter._();
|
||||
|
||||
static final _dateFormat = DateFormat('dd MMM yyyy');
|
||||
static final _dateTimeFormat = DateFormat('dd MMM yyyy, hh:mm a');
|
||||
/// Application-wide display format: DD/MM/YYYY
|
||||
static const displayDatePattern = 'dd/MM/yyyy';
|
||||
static const displayDateTimePattern = 'dd/MM/yyyy, hh:mm a';
|
||||
|
||||
static final _dateFormat = DateFormat(displayDatePattern);
|
||||
static final _dateTimeFormat = DateFormat(displayDateTimePattern);
|
||||
static final _apiDateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
static String displayDate(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return _dateFormat.format(date);
|
||||
return _dateFormat.format(date.toLocal());
|
||||
}
|
||||
|
||||
static String displayDateTime(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return _dateTimeFormat.format(date);
|
||||
return _dateTimeFormat.format(date.toLocal());
|
||||
}
|
||||
|
||||
static String toApiDate(DateTime date) => _apiDateFormat.format(date);
|
||||
|
||||
@ -15,7 +15,8 @@ class LoginColors {
|
||||
|
||||
Color get pageBackground => theme.scaffoldBackgroundColor;
|
||||
|
||||
Color get cardBackground => AppColors.card;
|
||||
Color get cardBackground =>
|
||||
isDark ? colorScheme.surface : AppColors.card;
|
||||
|
||||
Color get headingColor => colorScheme.onSurface;
|
||||
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/theme/theme_provider.dart';
|
||||
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/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_dropdown.dart';
|
||||
import '../../../../shared/widgets/app_card.dart';
|
||||
@ -57,6 +60,7 @@ class _UsersRoleManagementScreenState
|
||||
final saved = await showSidePanel<bool>(
|
||||
context,
|
||||
AddUserPanel(userId: userId),
|
||||
width: 560,
|
||||
);
|
||||
if (saved == true && mounted) {
|
||||
ref.invalidate(usersListProvider);
|
||||
@ -409,7 +413,7 @@ class _UsersTab extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
static const _tableMinWidth = 1040.0;
|
||||
static const _tableMinWidth = 1120.0;
|
||||
|
||||
String? _selectedRoleName;
|
||||
String? _selectedDepartmentName;
|
||||
@ -417,7 +421,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
|
||||
String _formatLastLogin(DateTime? value) {
|
||||
if (value == null) return '—';
|
||||
return DateFormat('MMM d, yyyy h:mm a').format(value.toLocal());
|
||||
return DateFormatter.displayDateTime(value);
|
||||
}
|
||||
|
||||
int? _roleIdForFilter(UserFiltersModel? filters) {
|
||||
@ -517,6 +521,35 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportUsers() async {
|
||||
final file = await ref.read(usersListProvider.notifier).exportUsers();
|
||||
if (!mounted) return;
|
||||
|
||||
if (file == null) {
|
||||
final error = ref.read(usersListProvider).valueOrNull?.actionError;
|
||||
if (error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final saved = await downloadFile(
|
||||
bytes: file.bytes,
|
||||
fileName: file.fileName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
saved ? 'Downloaded ${file.fileName}' : 'Export cancelled',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deactivateUser(ManagedUserModel user) async {
|
||||
final confirmed = await showAppConfirmationDialog(
|
||||
context: context,
|
||||
@ -575,7 +608,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
|
||||
return AppCard(
|
||||
enableHover: false,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
clipBehavior: Clip.none,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@ -598,6 +631,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
roles: roles,
|
||||
departments: departments,
|
||||
statuses: statuses,
|
||||
isExporting: usersState.isExporting,
|
||||
onExport: _exportUsers,
|
||||
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
||||
onRoleChanged: (value) {
|
||||
setState(() {
|
||||
@ -645,9 +680,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: tableWidth),
|
||||
child: DataTable(
|
||||
horizontalMargin: 20,
|
||||
columnSpacing: 16,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
@ -662,7 +700,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
DataColumn(label: Text('PLANT')),
|
||||
DataColumn(label: Text('LAST LOGIN')),
|
||||
DataColumn(label: Text('STATUS')),
|
||||
DataColumn(label: Text('')),
|
||||
DataColumn(
|
||||
label: UserTableActionsHeader(),
|
||||
),
|
||||
],
|
||||
rows: usersState.users.map((user) {
|
||||
final status = userStatusFromApi(user.status);
|
||||
@ -707,11 +747,15 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
UserTableActions(
|
||||
user: user,
|
||||
onEdit: () => _editUser(user),
|
||||
onResetPassword: () => _resetPassword(user),
|
||||
onDeactivate: () => _deactivateUser(user),
|
||||
UserTableActionsCell(
|
||||
child: UserTableActions(
|
||||
user: user,
|
||||
onEdit: () => _editUser(user),
|
||||
onResetPassword: () =>
|
||||
_resetPassword(user),
|
||||
onDeactivate: () =>
|
||||
_deactivateUser(user),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -800,6 +844,8 @@ class _UsersFilterBar extends StatelessWidget {
|
||||
required this.departments,
|
||||
required this.statuses,
|
||||
required this.onSearch,
|
||||
required this.onExport,
|
||||
this.isExporting = false,
|
||||
required this.onRoleChanged,
|
||||
required this.onDepartmentChanged,
|
||||
required this.onStatusChanged,
|
||||
@ -813,6 +859,8 @@ class _UsersFilterBar extends StatelessWidget {
|
||||
final List<String> departments;
|
||||
final List<String> statuses;
|
||||
final ValueChanged<String> onSearch;
|
||||
final VoidCallback onExport;
|
||||
final bool isExporting;
|
||||
final ValueChanged<String> onRoleChanged;
|
||||
final ValueChanged<String> onDepartmentChanged;
|
||||
final ValueChanged<String> onStatusChanged;
|
||||
@ -848,9 +896,15 @@ class _UsersFilterBar extends StatelessWidget {
|
||||
onChanged: onStatusChanged,
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.download_outlined, size: 18),
|
||||
label: const Text('Export'),
|
||||
onPressed: isExporting ? null : onExport,
|
||||
icon: isExporting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download_outlined, size: 18),
|
||||
label: Text(isExporting ? 'Exporting...' : 'Export'),
|
||||
),
|
||||
];
|
||||
|
||||
@ -925,6 +979,9 @@ class _RolesTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rolesAsync = ref.watch(rolesListProvider);
|
||||
// Text styles are resolved at build time; rebuild cards when theme toggles.
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final brightness = Theme.of(context).brightness;
|
||||
|
||||
return rolesAsync.when(
|
||||
loading: () => const AppCard(
|
||||
@ -942,6 +999,7 @@ class _RolesTab extends ConsumerWidget {
|
||||
final roles = rolesState.roles;
|
||||
|
||||
return GridView.builder(
|
||||
key: ValueKey('$themeMode-$brightness'),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 320,
|
||||
mainAxisExtent: 190,
|
||||
@ -1020,6 +1078,7 @@ class _RolesTab extends ConsumerWidget {
|
||||
role.name,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@ -1043,7 +1102,9 @@ class _RolesTab extends ConsumerWidget {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.userCount} users',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
@ -1054,7 +1115,9 @@ class _RolesTab extends ConsumerWidget {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${role.permissionCount} permissions',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -1078,197 +1141,325 @@ class _PermissionMatrixTab extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
|
||||
String? _selectedRoleId;
|
||||
bool _isSaving = false;
|
||||
|
||||
void _ensureSelectedRole(List<RoleCardModel> roles) {
|
||||
if (roles.isEmpty) return;
|
||||
final exists = roles.any((role) => role.id == _selectedRoleId);
|
||||
if (_selectedRoleId == null || !exists) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedRoleId = roles.first.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save(String roleName) async {
|
||||
final roleId = _selectedRoleId;
|
||||
if (roleId == null) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final success =
|
||||
await ref.read(permissionMatrixProvider(roleId).notifier).save();
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
success ? Icons.check_circle : Icons.error_outline,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
success
|
||||
? 'Permissions saved for $roleName'
|
||||
: 'Failed to save permissions',
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor:
|
||||
success ? const Color(0xFF16A34A) : Theme.of(context).colorScheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(rbacProvider);
|
||||
final role = state.selectedRole;
|
||||
final rolesAsync = ref.watch(rolesListProvider);
|
||||
final catalogAsync = ref.watch(permissionCatalogProvider);
|
||||
final selectedRoleId = _selectedRoleId;
|
||||
final matrixAsync = selectedRoleId == null
|
||||
? null
|
||||
: ref.watch(permissionMatrixProvider(selectedRoleId));
|
||||
|
||||
return AppCard(
|
||||
enableHover: false,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.12),
|
||||
return rolesAsync.when(
|
||||
loading: () => const AppCard(
|
||||
enableHover: false,
|
||||
child: AppLoadingView(message: 'Loading roles...'),
|
||||
),
|
||||
error: (error, _) => AppCard(
|
||||
enableHover: false,
|
||||
child: ErrorView.fromFailure(
|
||||
error is Failure ? error : Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(rolesListProvider),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Role permission matrix',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
data: (rolesState) {
|
||||
final roles = rolesState.roles;
|
||||
_ensureSelectedRole(roles);
|
||||
|
||||
RoleCardModel? selectedRole;
|
||||
for (final role in roles) {
|
||||
if (role.id == selectedRoleId) {
|
||||
selectedRole = role;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return AppCard(
|
||||
enableHover: false,
|
||||
clipBehavior: Clip.none,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Role permission matrix',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Select a role below and toggle module permissions.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Select a role below and toggle module permissions.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: roles.map((role) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: RolePill(
|
||||
label: role.name,
|
||||
selected: role.id == selectedRoleId,
|
||||
onTap: () => setState(() => _selectedRoleId = role.id),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: selectedRoleId == null
|
||||
? const Center(child: Text('No roles available'))
|
||||
: matrixAsync == null
|
||||
? const AppLoadingView(message: 'Loading permissions...')
|
||||
: matrixAsync.when(
|
||||
loading: () => const AppLoadingView(
|
||||
message: 'Loading permission matrix...',
|
||||
),
|
||||
error: (error, _) => ErrorView.fromFailure(
|
||||
error is Failure
|
||||
? error
|
||||
: Failure.unknown(message: error.toString()),
|
||||
onRetry: () => ref.invalidate(
|
||||
permissionMatrixProvider(selectedRoleId),
|
||||
),
|
||||
),
|
||||
data: (matrix) => _PermissionMatrixTable(
|
||||
roleId: selectedRoleId,
|
||||
matrix: matrix,
|
||||
catalog: catalogAsync.valueOrNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermissionMatrixTable extends ConsumerWidget {
|
||||
const _PermissionMatrixTable({
|
||||
required this.roleId,
|
||||
required this.matrix,
|
||||
this.catalog,
|
||||
});
|
||||
|
||||
final String roleId;
|
||||
final RolePermissionMatrix matrix;
|
||||
final List<PermissionModuleCatalog>? catalog;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final actionColumns = matrix.actionColumns.isNotEmpty
|
||||
? matrix.actionColumns
|
||||
: (catalog?.isNotEmpty == true
|
||||
? catalog!.first.actions
|
||||
: permissionMatrixActionOrder);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tableMinWidth = 180.0 + (actionColumns.length * 88.0);
|
||||
final tableWidth = constraints.maxWidth < tableMinWidth
|
||||
? tableMinWidth
|
||||
: constraints.maxWidth;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: tableWidth - 32),
|
||||
child: Table(
|
||||
columnWidths: {
|
||||
0: const FlexColumnWidth(2.5),
|
||||
for (var i = 0; i < actionColumns.length; i++)
|
||||
i + 1: const FlexColumnWidth(1),
|
||||
},
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
children: [
|
||||
const _MatrixHeader('MODULE'),
|
||||
...actionColumns.map(
|
||||
(action) => _MatrixHeader(permissionActionLabel(action)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: role == null
|
||||
? null
|
||||
: () {
|
||||
ref.read(rbacProvider.notifier).savePermissions();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle,
|
||||
color: Colors.white, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Permissions saved for ${role.name}',
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: const Color(0xFF16A34A),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.save_outlined, size: 18),
|
||||
label: const Text('Save changes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: state.roles.map((r) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: RolePill(
|
||||
label: r.name,
|
||||
selected: r.id == state.selectedRoleId,
|
||||
onTap: () =>
|
||||
ref.read(rbacProvider.notifier).selectRole(r.id),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: role == null
|
||||
? const SizedBox.shrink()
|
||||
: Table(
|
||||
columnWidths: const {
|
||||
0: FlexColumnWidth(2.5),
|
||||
1: FlexColumnWidth(1),
|
||||
2: FlexColumnWidth(1),
|
||||
3: FlexColumnWidth(1),
|
||||
4: FlexColumnWidth(1),
|
||||
5: FlexColumnWidth(1),
|
||||
6: FlexColumnWidth(1),
|
||||
},
|
||||
border: TableBorder(
|
||||
horizontalInside: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
...matrix.modules.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final module = entry.value;
|
||||
final appearance =
|
||||
permissionModuleAppearance(module.code, index);
|
||||
|
||||
return TableRow(
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
children: [
|
||||
const _MatrixHeader('MODULE'),
|
||||
...RbacAction.values
|
||||
.map((a) => _MatrixHeader(a.label)),
|
||||
],
|
||||
),
|
||||
...rbacModules.map((module) {
|
||||
return TableRow(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: module.color
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
module.icon,
|
||||
size: 16,
|
||||
color: module.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
module.label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: appearance.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
appearance.icon,
|
||||
size: 16,
|
||||
color: appearance.color,
|
||||
),
|
||||
),
|
||||
...RbacAction.values.map((action) {
|
||||
final checked =
|
||||
role.hasPermission(module.key, action);
|
||||
return Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Center(
|
||||
child: Checkbox(
|
||||
value: checked,
|
||||
onChanged: (_) => ref
|
||||
.read(rbacProvider.notifier)
|
||||
.togglePermission(
|
||||
module.key,
|
||||
action,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
module.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...actionColumns.map((action) {
|
||||
final checked = module.granted[action] ?? false;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Center(
|
||||
child: Checkbox(
|
||||
value: checked,
|
||||
onChanged: (value) => ref
|
||||
.read(
|
||||
permissionMatrixProvider(roleId).notifier,
|
||||
)
|
||||
.toggleAction(
|
||||
module.moduleId,
|
||||
action,
|
||||
value ?? false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,25 +188,23 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
||||
|
||||
return SidePanelScaffold(
|
||||
title: widget.isEditing ? 'Edit user' : 'Add new user',
|
||||
title: widget.isEditing ? 'Edit user' : 'Add user',
|
||||
footer: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context, rootNavigator: true).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: widget.isEditing ? 'Update user' : 'Save user',
|
||||
expand: true,
|
||||
isLoading: isSubmitting,
|
||||
onPressed: isSubmitting ? null : _save,
|
||||
),
|
||||
AppButton(
|
||||
label: widget.isEditing ? 'Update user' : 'Save user',
|
||||
expand: false,
|
||||
icon: Icons.check,
|
||||
isLoading: isSubmitting,
|
||||
onPressed: isSubmitting ? null : _save,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -236,92 +234,95 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SidePanelSection(
|
||||
title: 'PERSONAL DETAILS',
|
||||
children: [
|
||||
AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Full name *',
|
||||
hint: 'e.g. Ravi Kumar',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(
|
||||
controller: _employeeCodeController,
|
||||
label: 'Employee code *',
|
||||
hint: 'e.g. EMP002',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'Employee code'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email *',
|
||||
hint: 'ravi@company.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: Validators.email,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppTextField(
|
||||
controller: _mobileController,
|
||||
label: 'Mobile',
|
||||
hint: '9XXXXXXXXX',
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
],
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _nameController,
|
||||
label: 'Full name *',
|
||||
hint: 'e.g. Ravi Kumar',
|
||||
validator: (v) => Validators.required(v, fieldName: 'Name'),
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _employeeCodeController,
|
||||
label: 'Employee code *',
|
||||
hint: 'e.g. EMP002',
|
||||
validator: (v) =>
|
||||
Validators.required(v, fieldName: 'Employee code'),
|
||||
),
|
||||
),
|
||||
SidePanelFormRow(
|
||||
left: AppTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email *',
|
||||
hint: 'ravi@company.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: Validators.email,
|
||||
),
|
||||
right: AppTextField(
|
||||
controller: _mobileController,
|
||||
label: 'Mobile',
|
||||
hint: '9XXXXXXXXX',
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SidePanelSection(
|
||||
title: 'ROLE & ACCESS',
|
||||
children: [
|
||||
_buildDropdown(
|
||||
label: 'Role',
|
||||
value: _selectedRoleId,
|
||||
options: formState.roles,
|
||||
required: true,
|
||||
onChanged: (v) => setState(() => _selectedRoleId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppSearchableDropdown<String>(
|
||||
label: 'Status',
|
||||
value: _selectedStatus,
|
||||
options: _statusOptions,
|
||||
searchHint: 'Search status...',
|
||||
onChanged: (v) => setState(() => _selectedStatus = v ?? 'Active'),
|
||||
SidePanelFormRow(
|
||||
left: _buildDropdown(
|
||||
label: 'Role',
|
||||
value: _selectedRoleId,
|
||||
options: formState.roles,
|
||||
required: true,
|
||||
onChanged: (v) => setState(() => _selectedRoleId = v),
|
||||
),
|
||||
right: AppSearchableDropdown<String>(
|
||||
label: 'Status',
|
||||
value: _selectedStatus,
|
||||
options: _statusOptions,
|
||||
searchHint: 'Search status...',
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedStatus = v ?? 'Active'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SidePanelSection(
|
||||
title: 'ORGANISATION',
|
||||
children: [
|
||||
_buildDropdown(
|
||||
label: 'Department',
|
||||
value: _selectedDepartmentId,
|
||||
options: formState.departments,
|
||||
onChanged: (v) => setState(() => _selectedDepartmentId = v),
|
||||
SidePanelFormRow(
|
||||
left: _buildDropdown(
|
||||
label: 'Department',
|
||||
value: _selectedDepartmentId,
|
||||
options: formState.departments,
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedDepartmentId = v),
|
||||
),
|
||||
right: _buildDropdown(
|
||||
label: 'Designation',
|
||||
value: _selectedDesignationId,
|
||||
options: formState.designations,
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedDesignationId = v),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDropdown(
|
||||
label: 'Designation',
|
||||
value: _selectedDesignationId,
|
||||
options: formState.designations,
|
||||
onChanged: (v) => setState(() => _selectedDesignationId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDropdown(
|
||||
label: 'Plant / Unit',
|
||||
value: _selectedPlantId,
|
||||
options: formState.plants,
|
||||
onChanged: (v) => setState(() => _selectedPlantId = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDropdown(
|
||||
label: 'Reporting to',
|
||||
value: _selectedReportingToId,
|
||||
options: formState.managers,
|
||||
hint: formState.managers.isEmpty
|
||||
? 'No managers available'
|
||||
: 'Select manager',
|
||||
onChanged: (v) => setState(() => _selectedReportingToId = v),
|
||||
SidePanelFormRow(
|
||||
left: _buildDropdown(
|
||||
label: 'Plant / Unit',
|
||||
value: _selectedPlantId,
|
||||
options: formState.plants,
|
||||
onChanged: (v) => setState(() => _selectedPlantId = v),
|
||||
),
|
||||
right: _buildDropdown(
|
||||
label: 'Reporting to',
|
||||
value: _selectedReportingToId,
|
||||
options: formState.managers,
|
||||
hint: formState.managers.isEmpty
|
||||
? 'No managers available'
|
||||
: 'Select manager',
|
||||
onChanged: (v) =>
|
||||
setState(() => _selectedReportingToId = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -339,12 +340,14 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
|
||||
? null
|
||||
: (v) => Validators.required(v, fieldName: 'Password'),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.isEditing
|
||||
? 'Leave blank to keep the current password.'
|
||||
: 'User will be asked to change this on first login.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -193,6 +193,8 @@ class UserTableActions extends StatelessWidget {
|
||||
required this.onDeactivate,
|
||||
});
|
||||
|
||||
static const columnWidth = 120.0;
|
||||
|
||||
final ManagedUserModel user;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onResetPassword;
|
||||
@ -204,29 +206,24 @@ class UserTableActions extends StatelessWidget {
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
_UserActionIcon(
|
||||
tooltip: 'Edit user',
|
||||
icon: Icon(Icons.edit_outlined, size: 18, color: muted),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
icon: Icons.edit_outlined,
|
||||
color: muted,
|
||||
onPressed: onEdit,
|
||||
),
|
||||
IconButton(
|
||||
_UserActionIcon(
|
||||
tooltip: 'Reset password',
|
||||
icon: Icon(Icons.vpn_key_outlined, size: 18, color: muted),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
icon: Icons.vpn_key_outlined,
|
||||
color: muted,
|
||||
onPressed: onResetPassword,
|
||||
),
|
||||
IconButton(
|
||||
_UserActionIcon(
|
||||
tooltip: 'Deactivate user',
|
||||
icon: Icon(Icons.person_off_outlined, size: 18, color: muted),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
icon: Icons.person_off_outlined,
|
||||
color: muted,
|
||||
onPressed: onDeactivate,
|
||||
),
|
||||
],
|
||||
@ -234,6 +231,70 @@ class UserTableActions extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class UserTableActionsHeader extends StatelessWidget {
|
||||
const UserTableActionsHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
width: UserTableActions.columnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text('ACTIONS'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserTableActionsCell extends StatelessWidget {
|
||||
const UserTableActionsCell({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: UserTableActions.columnWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserActionIcon extends StatelessWidget {
|
||||
const _UserActionIcon({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RolePill extends StatelessWidget {
|
||||
const RolePill({
|
||||
super.key,
|
||||
@ -280,9 +341,16 @@ class RolePill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<T?> showSidePanel<T>(BuildContext context, Widget panel) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final panelWidth = width > 1200 ? 480.0 : (width * 0.38).clamp(360.0, 480.0);
|
||||
Future<T?> showSidePanel<T>(
|
||||
BuildContext context,
|
||||
Widget panel, {
|
||||
double? width,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final defaultWidth = screenWidth > 1200
|
||||
? 480.0
|
||||
: (screenWidth * 0.38).clamp(360.0, 480.0);
|
||||
final panelWidth = (width ?? defaultWidth).clamp(360.0, screenWidth * 0.95);
|
||||
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
@ -386,6 +454,9 @@ class SidePanelSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dividerColor =
|
||||
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -397,7 +468,9 @@ class SidePanelSection extends StatelessWidget {
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Divider(height: 1, color: dividerColor),
|
||||
const SizedBox(height: 16),
|
||||
...children,
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
@ -405,6 +478,55 @@ class SidePanelSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-column row for side panel forms (desktop layout).
|
||||
class SidePanelFormRow extends StatelessWidget {
|
||||
const SidePanelFormRow({
|
||||
super.key,
|
||||
required this.left,
|
||||
required this.right,
|
||||
this.spacing = 16,
|
||||
});
|
||||
|
||||
final Widget left;
|
||||
final Widget right;
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final stack = constraints.maxWidth < 420;
|
||||
|
||||
if (stack) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
left,
|
||||
const SizedBox(height: 12),
|
||||
right,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: left),
|
||||
SizedBox(width: spacing),
|
||||
Expanded(child: right),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ModulePermissionRow extends StatelessWidget {
|
||||
const ModulePermissionRow({
|
||||
super.key,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
import '../../../../shared/models/role_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
|
||||
@ -72,23 +73,28 @@ class RoleRemoteDataSource {
|
||||
await dio.delete(ApiEndpoints.roleById(id));
|
||||
}
|
||||
|
||||
Future<List<PermissionCatalogModel>> getPermissionCatalog() async {
|
||||
Future<List<PermissionModuleCatalog>> getPermissionCatalog() async {
|
||||
final response = await dio.get(ApiEndpoints.rolesPermissions);
|
||||
return _parseList(response.data['data'], PermissionCatalogModel.fromJson);
|
||||
return _parseList(
|
||||
response.data['data'],
|
||||
PermissionModuleCatalog.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PermissionMatrixModel> getPermissionMatrix(String roleId) async {
|
||||
Future<RolePermissionMatrix> getPermissionMatrix(String roleId) async {
|
||||
final response = await dio.get(ApiEndpoints.rolePermissionMatrix(roleId));
|
||||
return PermissionMatrixModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
return RolePermissionMatrix.fromApiJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> savePermissionMatrix(
|
||||
String roleId,
|
||||
PermissionMatrixSaveRequest request,
|
||||
RolePermissionMatrix matrix,
|
||||
) async {
|
||||
await dio.put(
|
||||
ApiEndpoints.rolePermissionMatrix(roleId),
|
||||
data: request.toApiJson(),
|
||||
data: matrix.toSaveJson(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/role_model.dart';
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../domain/repositories/role_repository.dart';
|
||||
import '../datasources/role_remote_data_source.dart';
|
||||
@ -49,17 +50,17 @@ class RoleRepositoryImpl implements RoleRepository {
|
||||
safeApiCall(() => remote.deleteRole(id));
|
||||
|
||||
@override
|
||||
Future<Result<List<PermissionCatalogModel>>> getPermissionCatalog() =>
|
||||
Future<Result<List<PermissionModuleCatalog>>> getPermissionCatalog() =>
|
||||
safeApiCall(remote.getPermissionCatalog);
|
||||
|
||||
@override
|
||||
Future<Result<PermissionMatrixModel>> getPermissionMatrix(String roleId) =>
|
||||
Future<Result<RolePermissionMatrix>> getPermissionMatrix(String roleId) =>
|
||||
safeApiCall(() => remote.getPermissionMatrix(roleId));
|
||||
|
||||
@override
|
||||
Future<Result<void>> savePermissionMatrix(
|
||||
String roleId,
|
||||
PermissionMatrixSaveRequest request,
|
||||
RolePermissionMatrix matrix,
|
||||
) =>
|
||||
safeApiCall(() => remote.savePermissionMatrix(roleId, request));
|
||||
safeApiCall(() => remote.savePermissionMatrix(roleId, matrix));
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
import '../../../../shared/models/role_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
|
||||
@ -10,10 +11,10 @@ abstract class RoleRepository {
|
||||
Future<Result<RoleModel>> createRole(CreateRoleRequest request);
|
||||
Future<Result<RoleModel>> updateRole(String id, UpdateRoleRequest request);
|
||||
Future<Result<void>> deleteRole(String id);
|
||||
Future<Result<List<PermissionCatalogModel>>> getPermissionCatalog();
|
||||
Future<Result<PermissionMatrixModel>> getPermissionMatrix(String roleId);
|
||||
Future<Result<List<PermissionModuleCatalog>>> getPermissionCatalog();
|
||||
Future<Result<RolePermissionMatrix>> getPermissionMatrix(String roleId);
|
||||
Future<Result<void>> savePermissionMatrix(
|
||||
String roleId,
|
||||
PermissionMatrixSaveRequest request,
|
||||
RolePermissionMatrix matrix,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
import '../../../../shared/models/role_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../repositories/role_repository.dart';
|
||||
@ -25,7 +26,7 @@ class GetPermissionMatrixUseCase {
|
||||
|
||||
final RoleRepository _repository;
|
||||
|
||||
Future<Result<PermissionMatrixModel>> call(String roleId) =>
|
||||
Future<Result<RolePermissionMatrix>> call(String roleId) =>
|
||||
_repository.getPermissionMatrix(roleId);
|
||||
}
|
||||
|
||||
@ -34,8 +35,8 @@ class SavePermissionMatrixUseCase {
|
||||
|
||||
final RoleRepository _repository;
|
||||
|
||||
Future<Result<void>> call(String roleId, PermissionMatrixSaveRequest request) =>
|
||||
_repository.savePermissionMatrix(roleId, request);
|
||||
Future<Result<void>> call(String roleId, RolePermissionMatrix matrix) =>
|
||||
_repository.savePermissionMatrix(roleId, matrix);
|
||||
}
|
||||
|
||||
class CreateRoleUseCase {
|
||||
@ -69,6 +70,6 @@ class GetPermissionCatalogUseCase {
|
||||
|
||||
final RoleRepository _repository;
|
||||
|
||||
Future<Result<List<PermissionCatalogModel>>> call() =>
|
||||
Future<Result<List<PermissionModuleCatalog>>> call() =>
|
||||
_repository.getPermissionCatalog();
|
||||
}
|
||||
|
||||
@ -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 '../../data/repositories/role_repository_impl.dart';
|
||||
import '../../domain/usecases/role_usecases.dart';
|
||||
@ -44,6 +45,10 @@ final deleteRoleUseCaseProvider = Provider((ref) {
|
||||
return DeleteRoleUseCase(ref.watch(roleRepositoryProvider));
|
||||
});
|
||||
|
||||
final getPermissionCatalogUseCaseProvider = Provider((ref) {
|
||||
return GetPermissionCatalogUseCase(ref.watch(roleRepositoryProvider));
|
||||
});
|
||||
|
||||
final getPermissionMatrixUseCaseProvider = Provider((ref) {
|
||||
return GetPermissionMatrixUseCase(ref.watch(roleRepositoryProvider));
|
||||
});
|
||||
@ -52,6 +57,13 @@ final savePermissionMatrixUseCaseProvider = Provider((ref) {
|
||||
return SavePermissionMatrixUseCase(ref.watch(roleRepositoryProvider));
|
||||
});
|
||||
|
||||
final permissionCatalogProvider =
|
||||
FutureProvider<List<PermissionModuleCatalog>>((ref) async {
|
||||
final result = await ref.read(getPermissionCatalogUseCaseProvider)();
|
||||
if (result.failure != null) throw result.failure!;
|
||||
return result.data ?? const [];
|
||||
});
|
||||
|
||||
final rolesListProvider =
|
||||
AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new);
|
||||
|
||||
@ -93,12 +105,13 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
|
||||
|
||||
final permissionMatrixProvider = AsyncNotifierProvider.family<
|
||||
PermissionMatrixNotifier,
|
||||
PermissionMatrixModel,
|
||||
RolePermissionMatrix,
|
||||
String>(PermissionMatrixNotifier.new);
|
||||
|
||||
class PermissionMatrixNotifier extends FamilyAsyncNotifier<PermissionMatrixModel, String> {
|
||||
class PermissionMatrixNotifier
|
||||
extends FamilyAsyncNotifier<RolePermissionMatrix, String> {
|
||||
@override
|
||||
Future<PermissionMatrixModel> build(String arg) async {
|
||||
Future<RolePermissionMatrix> build(String arg) async {
|
||||
final useCase = ref.read(getPermissionMatrixUseCaseProvider);
|
||||
final result = await useCase(arg);
|
||||
if (result.failure != null) throw result.failure!;
|
||||
@ -109,20 +122,14 @@ class PermissionMatrixNotifier extends FamilyAsyncNotifier<PermissionMatrixModel
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
|
||||
final updated = current.matrix.map((row) {
|
||||
final updated = current.modules.map((row) {
|
||||
if (row.moduleId != moduleId) return row;
|
||||
final actions = row.actions;
|
||||
final next = switch (action) {
|
||||
'view' => actions.copyWith(view: value),
|
||||
'edit' => actions.copyWith(edit: value),
|
||||
'approve' => actions.copyWith(approve: value),
|
||||
'export' => actions.copyWith(export: value),
|
||||
_ => actions,
|
||||
};
|
||||
return row.copyWith(actions: next);
|
||||
final granted = Map<String, bool>.from(row.granted);
|
||||
granted[action] = value;
|
||||
return row.copyWith(granted: granted);
|
||||
}).toList();
|
||||
|
||||
state = AsyncData(current.copyWith(matrix: updated));
|
||||
state = AsyncData(current.copyWith(modules: updated));
|
||||
}
|
||||
|
||||
Future<bool> save() async {
|
||||
@ -130,17 +137,7 @@ class PermissionMatrixNotifier extends FamilyAsyncNotifier<PermissionMatrixModel
|
||||
if (current == null) return false;
|
||||
|
||||
final useCase = ref.read(savePermissionMatrixUseCaseProvider);
|
||||
final request = PermissionMatrixSaveRequest(
|
||||
matrix: current.matrix
|
||||
.map(
|
||||
(row) => PermissionMatrixSaveRow(
|
||||
moduleId: row.moduleId,
|
||||
actions: row.actions,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
final result = await useCase(arg, request);
|
||||
final result = await useCase(arg, current);
|
||||
if (result.failure != null) return false;
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
|
||||
@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/models/permission_matrix_models.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
@ -80,7 +80,7 @@ class _MatrixGrid extends ConsumerWidget {
|
||||
const _MatrixGrid({required this.roleId, required this.matrix});
|
||||
|
||||
final String roleId;
|
||||
final PermissionMatrixModel matrix;
|
||||
final RolePermissionMatrix matrix;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@ -88,22 +88,28 @@ class _MatrixGrid extends ConsumerWidget {
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: const [
|
||||
DataColumn(label: Text('Module')),
|
||||
DataColumn(label: Text('View')),
|
||||
DataColumn(label: Text('Edit')),
|
||||
DataColumn(label: Text('Approve')),
|
||||
DataColumn(label: Text('Export')),
|
||||
columns: [
|
||||
const DataColumn(label: Text('Module')),
|
||||
...matrix.actionColumns.map(
|
||||
(action) => DataColumn(label: Text(permissionActionLabel(action))),
|
||||
),
|
||||
],
|
||||
rows: matrix.matrix
|
||||
rows: matrix.modules
|
||||
.map(
|
||||
(row) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(row.module)),
|
||||
DataCell(_actionToggle(roleId, row, 'view', row.actions.view, ref)),
|
||||
DataCell(_actionToggle(roleId, row, 'edit', row.actions.edit, ref)),
|
||||
DataCell(_actionToggle(roleId, row, 'approve', row.actions.approve, ref)),
|
||||
DataCell(_actionToggle(roleId, row, 'export', row.actions.export, ref)),
|
||||
DataCell(Text(row.name)),
|
||||
...matrix.actionColumns.map(
|
||||
(action) => DataCell(
|
||||
_actionToggle(
|
||||
roleId,
|
||||
row,
|
||||
action,
|
||||
row.granted[action] ?? false,
|
||||
ref,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@ -115,7 +121,7 @@ class _MatrixGrid extends ConsumerWidget {
|
||||
|
||||
Widget _actionToggle(
|
||||
String roleId,
|
||||
PermissionMatrixRow row,
|
||||
PermissionMatrixModuleRow row,
|
||||
String action,
|
||||
bool value,
|
||||
WidgetRef ref,
|
||||
@ -133,48 +139,29 @@ class _MatrixCardList extends ConsumerWidget {
|
||||
const _MatrixCardList({required this.roleId, required this.matrix});
|
||||
|
||||
final String roleId;
|
||||
final PermissionMatrixModel matrix;
|
||||
final RolePermissionMatrix matrix;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView.separated(
|
||||
itemCount: matrix.matrix.length,
|
||||
itemCount: matrix.modules.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final row = matrix.matrix[index];
|
||||
final row = matrix.modules[index];
|
||||
return AppCard(
|
||||
child: ExpansionTile(
|
||||
title: Text(row.module),
|
||||
children: [
|
||||
_PermissionSwitch(
|
||||
label: 'View',
|
||||
value: row.actions.view,
|
||||
onChanged: (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, 'view', v),
|
||||
),
|
||||
_PermissionSwitch(
|
||||
label: 'Edit (Add & Edit)',
|
||||
value: row.actions.edit,
|
||||
onChanged: (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, 'edit', v),
|
||||
),
|
||||
_PermissionSwitch(
|
||||
label: 'Approve',
|
||||
value: row.actions.approve,
|
||||
onChanged: (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, 'approve', v),
|
||||
),
|
||||
_PermissionSwitch(
|
||||
label: 'Export',
|
||||
value: row.actions.export,
|
||||
onChanged: (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, 'export', v),
|
||||
),
|
||||
],
|
||||
title: Text(row.name),
|
||||
children: matrix.actionColumns
|
||||
.map(
|
||||
(action) => _PermissionSwitch(
|
||||
label: permissionActionLabel(action),
|
||||
value: row.granted[action] ?? false,
|
||||
onChanged: (v) => ref
|
||||
.read(permissionMatrixProvider(roleId).notifier)
|
||||
.toggleAction(row.moduleId, action, v),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../domain/entities/app_settings.dart';
|
||||
@ -22,7 +23,6 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
|
||||
late String _timeZone;
|
||||
late String _language;
|
||||
late String _currency;
|
||||
late String _dateFormat;
|
||||
late String _timeFormat;
|
||||
late String _numberFormat;
|
||||
|
||||
@ -36,7 +36,6 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
|
||||
_timeZone = general.timeZone;
|
||||
_language = general.language;
|
||||
_currency = general.currency;
|
||||
_dateFormat = general.dateFormat;
|
||||
_timeFormat = general.timeFormat;
|
||||
_numberFormat = general.numberFormat;
|
||||
}
|
||||
@ -58,7 +57,7 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
|
||||
timeZone: _timeZone,
|
||||
language: _language,
|
||||
currency: _currency,
|
||||
dateFormat: _dateFormat,
|
||||
dateFormat: DateFormatter.displayDatePattern,
|
||||
timeFormat: _timeFormat,
|
||||
numberFormat: _numberFormat,
|
||||
),
|
||||
@ -135,12 +134,15 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
|
||||
onChanged: (v) => setState(() => _currency = v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsDropdownField<String>(
|
||||
label: 'Date Format',
|
||||
value: _dateFormat,
|
||||
items: const ['dd/MM/yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd'],
|
||||
itemLabel: (v) => v,
|
||||
onChanged: (v) => setState(() => _dateFormat = v!),
|
||||
InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date Format',
|
||||
enabled: false,
|
||||
),
|
||||
child: Text(
|
||||
'DD/MM/YYYY',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SettingsDropdownField<String>(
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/constants/api_endpoints.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
|
||||
class UserRemoteDataSource {
|
||||
@ -126,13 +127,17 @@ class UserRemoteDataSource {
|
||||
await dio.delete(ApiEndpoints.userById(id));
|
||||
}
|
||||
|
||||
Future<List<int>> exportUsers(UserListQuery query) async {
|
||||
Future<ExportFileResult> exportUsers(UserListQuery query) async {
|
||||
final response = await dio.get<List<int>>(
|
||||
ApiEndpoints.usersExport,
|
||||
queryParameters: _queryToMap(query),
|
||||
queryParameters: _exportQueryToMap(query),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return response.data ?? <int>[];
|
||||
final bytes = response.data ?? <int>[];
|
||||
return ExportFileResult(
|
||||
bytes: bytes,
|
||||
fileName: _fileNameFromResponse(response),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ManagedUserModel> updateProfile(UpdateProfileRequest request) async {
|
||||
@ -143,6 +148,43 @@ class UserRemoteDataSource {
|
||||
return ManagedUserModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _exportQueryToMap(UserListQuery query) {
|
||||
return {
|
||||
if (query.search != null && query.search!.isNotEmpty) 'search': query.search,
|
||||
if (query.status != null) 'status': query.status,
|
||||
if (query.roleId != null) 'role_id': query.roleId,
|
||||
if (query.departmentId != null) 'department_id': query.departmentId,
|
||||
if (query.sortBy != null) 'sort_by': query.sortBy,
|
||||
if (query.sortOrder.isNotEmpty) 'sort_order': query.sortOrder,
|
||||
if (query.isActive != null) 'is_active': query.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
String _fileNameFromResponse(Response<List<int>> response) {
|
||||
final disposition = response.headers.value('content-disposition');
|
||||
if (disposition != null) {
|
||||
final utf8Match = RegExp(
|
||||
r"filename\*=UTF-8''([^;\n]+)",
|
||||
caseSensitive: false,
|
||||
).firstMatch(disposition);
|
||||
if (utf8Match != null) {
|
||||
return Uri.decodeComponent(utf8Match.group(1)!);
|
||||
}
|
||||
|
||||
final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition);
|
||||
if (match != null) {
|
||||
return match.group(1)!.trim();
|
||||
}
|
||||
}
|
||||
|
||||
final contentType = response.headers.value('content-type')?.toLowerCase() ?? '';
|
||||
if (contentType.contains('csv')) return 'users_export.csv';
|
||||
if (contentType.contains('sheet') || contentType.contains('excel')) {
|
||||
return 'users_export.xlsx';
|
||||
}
|
||||
return 'users_export.xlsx';
|
||||
}
|
||||
|
||||
Map<String, dynamic> _queryToMap(UserListQuery query) {
|
||||
return {
|
||||
'page': query.page,
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../domain/repositories/user_repository.dart';
|
||||
import '../datasources/user_remote_data_source.dart';
|
||||
@ -54,7 +55,7 @@ class UserRepositoryImpl implements UserRepository {
|
||||
safeApiCall(() => remote.deleteUser(id));
|
||||
|
||||
@override
|
||||
Future<Result<List<int>>> exportUsers(UserListQuery query) =>
|
||||
Future<Result<ExportFileResult>> exportUsers(UserListQuery query) =>
|
||||
safeApiCall(() => remote.exportUsers(query));
|
||||
|
||||
@override
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
|
||||
abstract class UserRepository {
|
||||
@ -10,6 +11,6 @@ abstract class UserRepository {
|
||||
Future<Result<ManagedUserModel>> createUser(CreateUserRequest request);
|
||||
Future<Result<ManagedUserModel>> updateUser(String id, UpdateUserRequest request);
|
||||
Future<Result<void>> deleteUser(String id);
|
||||
Future<Result<List<int>>> exportUsers(UserListQuery query);
|
||||
Future<Result<ExportFileResult>> exportUsers(UserListQuery query);
|
||||
Future<Result<ManagedUserModel>> updateProfile(UpdateProfileRequest request);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../repositories/user_repository.dart';
|
||||
|
||||
@ -70,3 +71,12 @@ class UpdateProfileUseCase {
|
||||
Future<Result<ManagedUserModel>> call(UpdateProfileRequest request) =>
|
||||
_repository.updateProfile(request);
|
||||
}
|
||||
|
||||
class ExportUsersUseCase {
|
||||
ExportUsersUseCase(this._repository);
|
||||
|
||||
final UserRepository _repository;
|
||||
|
||||
Future<Result<ExportFileResult>> call(UserListQuery query) =>
|
||||
_repository.exportUsers(query);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/export_file_result.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../data/repositories/user_repository_impl.dart';
|
||||
import '../../domain/usecases/user_usecases.dart';
|
||||
@ -14,6 +14,7 @@ class UsersListState {
|
||||
this.total = 0,
|
||||
this.totalPages = 1,
|
||||
this.isRefreshing = false,
|
||||
this.isExporting = false,
|
||||
this.actionError,
|
||||
this.actionSuccess,
|
||||
});
|
||||
@ -25,6 +26,7 @@ class UsersListState {
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final bool isRefreshing;
|
||||
final bool isExporting;
|
||||
final String? actionError;
|
||||
final String? actionSuccess;
|
||||
|
||||
@ -36,6 +38,7 @@ class UsersListState {
|
||||
int? total,
|
||||
int? totalPages,
|
||||
bool? isRefreshing,
|
||||
bool? isExporting,
|
||||
String? actionError,
|
||||
String? actionSuccess,
|
||||
bool clearMessages = false,
|
||||
@ -48,6 +51,7 @@ class UsersListState {
|
||||
total: total ?? this.total,
|
||||
totalPages: totalPages ?? this.totalPages,
|
||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||
isExporting: isExporting ?? this.isExporting,
|
||||
actionError: clearMessages ? null : actionError ?? this.actionError,
|
||||
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
|
||||
);
|
||||
@ -86,6 +90,10 @@ final getUserByIdUseCaseProvider = Provider((ref) {
|
||||
return GetUserByIdUseCase(ref.watch(userRepositoryProvider));
|
||||
});
|
||||
|
||||
final exportUsersUseCaseProvider = Provider((ref) {
|
||||
return ExportUsersUseCase(ref.watch(userRepositoryProvider));
|
||||
});
|
||||
|
||||
final usersListProvider =
|
||||
AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new);
|
||||
|
||||
@ -174,6 +182,29 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
|
||||
applyQuery(current.query.copyWith(departmentId: departmentId, page: 1));
|
||||
}
|
||||
|
||||
Future<ExportFileResult?> exportUsers() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return null;
|
||||
|
||||
state = AsyncData(current.copyWith(isExporting: true, clearMessages: true));
|
||||
|
||||
final result = await ref.read(exportUsersUseCaseProvider)(current.query);
|
||||
final latest = state.valueOrNull ?? current;
|
||||
|
||||
if (result.failure != null) {
|
||||
state = AsyncData(
|
||||
latest.copyWith(
|
||||
isExporting: false,
|
||||
actionError: result.failure!.message,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
state = AsyncData(latest.copyWith(isExporting: false));
|
||||
return result.data;
|
||||
}
|
||||
|
||||
Future<bool> deactivateUser(String id) async {
|
||||
final result = await ref.read(deleteUserUseCaseProvider)(id);
|
||||
if (result.failure != null) {
|
||||
|
||||
@ -2,7 +2,7 @@ import '../../../../shared/widgets/app_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/utils/formatters.dart';
|
||||
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
@ -69,12 +69,12 @@ class UserDetailScreen extends ConsumerWidget {
|
||||
if (user.createdAt != null)
|
||||
_DetailRow(
|
||||
label: 'Created',
|
||||
value: DateFormat.yMMMd().add_jm().format(user.createdAt!),
|
||||
value: DateFormatter.displayDateTime(user.createdAt),
|
||||
),
|
||||
if (user.updatedAt != null)
|
||||
_DetailRow(
|
||||
label: 'Updated',
|
||||
value: DateFormat.yMMMd().add_jm().format(user.updatedAt!),
|
||||
value: DateFormatter.displayDateTime(user.updatedAt),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
9
lib/shared/models/export_file_result.dart
Normal file
9
lib/shared/models/export_file_result.dart
Normal file
@ -0,0 +1,9 @@
|
||||
class ExportFileResult {
|
||||
const ExportFileResult({
|
||||
required this.bytes,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
final List<int> bytes;
|
||||
final String fileName;
|
||||
}
|
||||
191
lib/shared/models/permission_matrix_models.dart
Normal file
191
lib/shared/models/permission_matrix_models.dart
Normal file
@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Column actions returned by `GET /roles/{id}/permission-matrix`.
|
||||
const permissionMatrixActionOrder = [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'approve',
|
||||
'export',
|
||||
];
|
||||
|
||||
String permissionActionLabel(String action) => switch (action) {
|
||||
'view' => 'VIEW',
|
||||
'create' => 'CREATE',
|
||||
'edit' => 'EDIT',
|
||||
'delete' => 'DELETE',
|
||||
'approve' => 'APPROVE',
|
||||
'export' => 'EXPORT',
|
||||
_ => action.toUpperCase(),
|
||||
};
|
||||
|
||||
List<String> sortPermissionActions(Iterable<String> actions) {
|
||||
final set = actions.map((a) => a.toLowerCase()).toSet();
|
||||
return permissionMatrixActionOrder.where(set.contains).toList();
|
||||
}
|
||||
|
||||
/// Module row from `GET /roles/permissions`.
|
||||
class PermissionModuleCatalog {
|
||||
const PermissionModuleCatalog({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String code;
|
||||
final String name;
|
||||
final List<String> actions;
|
||||
|
||||
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();
|
||||
|
||||
return PermissionModuleCatalog(
|
||||
id: json['id']?.toString() ?? '',
|
||||
code: json['code'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
actions: sortPermissionActions(actions),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionMatrixModuleRow {
|
||||
const PermissionMatrixModuleRow({
|
||||
required this.moduleId,
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.granted,
|
||||
});
|
||||
|
||||
final String moduleId;
|
||||
final String code;
|
||||
final String name;
|
||||
final Map<String, bool> granted;
|
||||
|
||||
PermissionMatrixModuleRow copyWith({
|
||||
Map<String, bool>? granted,
|
||||
}) {
|
||||
return PermissionMatrixModuleRow(
|
||||
moduleId: moduleId,
|
||||
code: code,
|
||||
name: name,
|
||||
granted: granted ?? this.granted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed `GET /roles/{id}/permission-matrix` payload.
|
||||
class RolePermissionMatrix {
|
||||
const RolePermissionMatrix({
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.actionColumns,
|
||||
required this.modules,
|
||||
});
|
||||
|
||||
final String roleId;
|
||||
final String roleName;
|
||||
final List<String> actionColumns;
|
||||
final List<PermissionMatrixModuleRow> modules;
|
||||
|
||||
RolePermissionMatrix copyWith({
|
||||
List<PermissionMatrixModuleRow>? modules,
|
||||
}) {
|
||||
return RolePermissionMatrix(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
actionColumns: actionColumns,
|
||||
modules: modules ?? this.modules,
|
||||
);
|
||||
}
|
||||
|
||||
factory RolePermissionMatrix.fromApiJson(Map<String, dynamic> json) {
|
||||
final role = json['role'] as Map<String, dynamic>? ?? const {};
|
||||
final actionColumns = sortPermissionActions(
|
||||
(json['actions'] as List<dynamic>? ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
);
|
||||
|
||||
final modules = <PermissionMatrixModuleRow>[];
|
||||
for (final raw in json['modules'] as List<dynamic>? ?? const []) {
|
||||
if (raw is! Map<String, dynamic>) continue;
|
||||
final permissions = raw['permissions'] as Map<String, dynamic>? ?? const {};
|
||||
final granted = <String, bool>{};
|
||||
|
||||
for (final action in actionColumns) {
|
||||
final entry = permissions[action];
|
||||
if (entry is Map<String, dynamic>) {
|
||||
granted[action] = entry['granted'] == true;
|
||||
} else {
|
||||
granted[action] = entry == true;
|
||||
}
|
||||
}
|
||||
|
||||
modules.add(
|
||||
PermissionMatrixModuleRow(
|
||||
moduleId: raw['module_id']?.toString() ?? '',
|
||||
code: raw['code'] as String? ?? '',
|
||||
name: raw['name'] as String? ?? '',
|
||||
granted: granted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RolePermissionMatrix(
|
||||
roleId: role['id']?.toString() ?? '',
|
||||
roleName: role['name'] as String? ?? '',
|
||||
actionColumns: actionColumns.isNotEmpty
|
||||
? actionColumns
|
||||
: List<String>.from(permissionMatrixActionOrder),
|
||||
modules: modules,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toSaveJson() {
|
||||
return {
|
||||
'matrix': modules
|
||||
.map(
|
||||
(module) => {
|
||||
'module_id': int.tryParse(module.moduleId) ?? module.moduleId,
|
||||
'actions': {
|
||||
for (final action in actionColumns)
|
||||
action: module.granted[action] ?? false,
|
||||
},
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
({IconData icon, Color color}) permissionModuleAppearance(String code, int index) {
|
||||
const styles = [
|
||||
(icon: Icons.people_outline, color: Color(0xFF2563EB)),
|
||||
(icon: Icons.shield_outlined, color: Color(0xFF16A34A)),
|
||||
(icon: Icons.dataset_outlined, color: Color(0xFFEA580C)),
|
||||
(icon: Icons.storefront_outlined, color: Color(0xFF7C3AED)),
|
||||
(icon: Icons.receipt_long_outlined, color: Color(0xFFCA8A04)),
|
||||
(icon: Icons.inventory_outlined, color: Color(0xFF0891B2)),
|
||||
(icon: Icons.inventory_2_outlined, color: Color(0xFFDC2626)),
|
||||
];
|
||||
|
||||
final normalized = code.toUpperCase();
|
||||
final byCode = switch (normalized) {
|
||||
'USERS' => styles[0],
|
||||
'ROLES' => styles[1],
|
||||
'MASTERS' => styles[2],
|
||||
'VENDOR' => styles[3],
|
||||
'PURCHASE_ORDERS' || 'PO' => styles[4],
|
||||
'GRN' => styles[5],
|
||||
'ASSETS' => styles[6],
|
||||
_ => styles[index % styles.length],
|
||||
};
|
||||
return byCode;
|
||||
}
|
||||
@ -46,6 +46,7 @@ import '../../modules/settings/presentation/screens/security_settings_screen.dar
|
||||
import '../../modules/settings/presentation/screens/settings_screen.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../widgets/app_shell.dart';
|
||||
import '../widgets/theme_keyed_subtree.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final refreshListenable = _AuthListenable(ref);
|
||||
@ -88,26 +89,29 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: RouteConstants.login,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
builder: (context, state) => _themedRoute(state, const LoginScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.forgotPassword,
|
||||
builder: (context, state) => const ForgotPasswordScreen(),
|
||||
builder: (context, state) =>
|
||||
_themedRoute(state, const ForgotPasswordScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.resetPassword,
|
||||
builder: (context, state) => const ResetPasswordScreen(),
|
||||
builder: (context, state) =>
|
||||
_themedRoute(state, const ResetPasswordScreen()),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.verifyOtp,
|
||||
builder: (context, state) {
|
||||
final email = state.uri.queryParameters['email'] ?? '';
|
||||
return VerifyOtpScreen(email: email);
|
||||
return _themedRoute(state, VerifyOtpScreen(email: email));
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.changePassword,
|
||||
builder: (context, state) => const ChangePasswordScreen(),
|
||||
builder: (context, state) =>
|
||||
_themedRoute(state, const ChangePasswordScreen()),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppShell(child: child),
|
||||
@ -308,6 +312,13 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return router;
|
||||
});
|
||||
|
||||
Widget _themedRoute(GoRouterState state, Widget child) {
|
||||
return ThemeKeyedSubtree(
|
||||
pageKey: state.matchedLocation,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
class _AuthListenable extends ChangeNotifier {
|
||||
_AuthListenable(this._ref) {
|
||||
_ref.listen(authStateProvider, (_, __) => notifyListeners());
|
||||
|
||||
15
lib/shared/utils/file_download_helper.dart
Normal file
15
lib/shared/utils/file_download_helper.dart
Normal file
@ -0,0 +1,15 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
/// Saves [bytes] as a file download (web: same-tab download, desktop: save dialog).
|
||||
Future<bool> downloadFile({
|
||||
required List<int> bytes,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final path = await FilePicker.saveFile(
|
||||
fileName: fileName,
|
||||
bytes: Uint8List.fromList(bytes),
|
||||
);
|
||||
return path != null;
|
||||
}
|
||||
15
lib/shared/utils/navigation_utils.dart
Normal file
15
lib/shared/utils/navigation_utils.dart
Normal file
@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Navigates to [location] after closing open dialogs, sheets, and overlays.
|
||||
void goAndDismissOverlays(BuildContext context, String location) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
|
||||
if (navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
context.go(location);
|
||||
}
|
||||
}
|
||||
@ -31,12 +31,24 @@ class AppCard extends StatefulWidget {
|
||||
|
||||
class _AppCardState extends State<AppCard> {
|
||||
bool _hovered = false;
|
||||
Brightness? _brightness;
|
||||
|
||||
void _setHovered(bool value) {
|
||||
if (!widget.enableHover || _hovered == value) return;
|
||||
setState(() => _hovered = value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final brightness = Theme.of(context).brightness;
|
||||
if (_brightness != brightness) {
|
||||
_brightness = brightness;
|
||||
_hovered = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@ -37,12 +37,24 @@ class AppHoverEffect extends StatefulWidget {
|
||||
|
||||
class _AppHoverEffectState extends State<AppHoverEffect> {
|
||||
bool _hovered = false;
|
||||
Brightness? _brightness;
|
||||
|
||||
void _setHovered(bool value) {
|
||||
if (!widget.enabled || _hovered == value) return;
|
||||
setState(() => _hovered = value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final brightness = Theme.of(context).brightness;
|
||||
if (_brightness != brightness) {
|
||||
_brightness = brightness;
|
||||
_hovered = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_dropdown.dart';
|
||||
|
||||
/// Dropdown that opens a searchable bottom sheet to pick an option.
|
||||
class AppSearchableDropdown<T> extends StatelessWidget {
|
||||
/// Dropdown that opens a searchable popup anchored to the field.
|
||||
class AppSearchableDropdown<T> extends StatefulWidget {
|
||||
const AppSearchableDropdown({
|
||||
super.key,
|
||||
required this.label,
|
||||
@ -27,82 +27,165 @@ class AppSearchableDropdown<T> extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final bool isDense;
|
||||
|
||||
@override
|
||||
State<AppSearchableDropdown<T>> createState() =>
|
||||
_AppSearchableDropdownState<T>();
|
||||
}
|
||||
|
||||
class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
||||
final _layerLink = LayerLink();
|
||||
final _fieldKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _labelForValue(T? selected) {
|
||||
if (selected == null) return null;
|
||||
for (final option in options) {
|
||||
for (final option in widget.options) {
|
||||
if (option.value == selected) return option.label;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _openPicker(BuildContext context, FormFieldState<T> field) async {
|
||||
if (!enabled || options.isEmpty) return;
|
||||
void _removeOverlay() {
|
||||
if (_overlayEntry == null) return;
|
||||
_overlayEntry!.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
void _openPicker(FormFieldState<T> field) {
|
||||
if (!widget.enabled || widget.options.isEmpty) return;
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
final selected = await showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: theme.bottomSheetTheme.backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
builder: (context) => Theme(
|
||||
data: theme,
|
||||
child: _SearchableDropdownSheet<T>(
|
||||
title: label,
|
||||
options: options,
|
||||
selected: value,
|
||||
searchHint: searchHint,
|
||||
),
|
||||
),
|
||||
final renderBox =
|
||||
_fieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final fieldSize = renderBox.size;
|
||||
final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final viewInsets = MediaQuery.viewInsetsOf(context);
|
||||
|
||||
final spaceBelow =
|
||||
screenSize.height - viewInsets.bottom - fieldTopLeft.dy - fieldSize.height;
|
||||
final spaceAbove = fieldTopLeft.dy - viewInsets.top;
|
||||
final showAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||
|
||||
final availableSpace = (showAbove ? spaceAbove : spaceBelow) - 8;
|
||||
final maxPanelHeight = availableSpace.clamp(120.0, screenSize.height * 0.45);
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) {
|
||||
final theme = Theme.of(overlayContext);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _removeOverlay,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor:
|
||||
showAbove ? Alignment.topLeft : Alignment.bottomLeft,
|
||||
followerAnchor:
|
||||
showAbove ? Alignment.bottomLeft : Alignment.topLeft,
|
||||
offset: Offset(0, showAbove ? -4 : 4),
|
||||
child: TapRegion(
|
||||
onTapOutside: (_) => _removeOverlay(),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: theme.colorScheme.surface,
|
||||
shadowColor: Colors.black45,
|
||||
child: SizedBox(
|
||||
width: fieldSize.width,
|
||||
child: _SearchableDropdownPanel<T>(
|
||||
maxHeight: maxPanelHeight,
|
||||
options: widget.options,
|
||||
selected: widget.value,
|
||||
searchHint: widget.searchHint,
|
||||
onSelected: (value) {
|
||||
_removeOverlay();
|
||||
field.didChange(value);
|
||||
widget.onChanged(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (selected == null) return;
|
||||
field.didChange(selected);
|
||||
onChanged(selected);
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final displayLabel = _labelForValue(value);
|
||||
final displayLabel = _labelForValue(widget.value);
|
||||
|
||||
return FormField<T>(
|
||||
initialValue: value,
|
||||
validator: validator,
|
||||
initialValue: widget.value,
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
final effectiveHint = hint ?? 'Select ${label.toLowerCase()}';
|
||||
final canOpen = enabled && options.isNotEmpty;
|
||||
final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
|
||||
final canOpen = widget.enabled && widget.options.isNotEmpty;
|
||||
final colors = theme.colorScheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: canOpen ? () => _openPicker(context, field) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
isFocused: false,
|
||||
isEmpty: displayLabel == null,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: displayLabel == null ? effectiveHint : null,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: isDense,
|
||||
errorText: field.errorText,
|
||||
suffixIcon: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: canOpen ? colors.onSurfaceVariant : theme.disabledColor,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: displayLabel == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
displayLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: KeyedSubtree(
|
||||
key: _fieldKey,
|
||||
child: InkWell(
|
||||
onTap: canOpen ? () => _openPicker(field) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
isFocused: _overlayEntry != null,
|
||||
isEmpty: displayLabel == null,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: displayLabel == null ? effectiveHint : null,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
isDense: widget.isDense,
|
||||
errorText: field.errorText,
|
||||
suffixIcon: Icon(
|
||||
_overlayEntry != null
|
||||
? Icons.arrow_drop_up
|
||||
: Icons.arrow_drop_down,
|
||||
color:
|
||||
canOpen ? colors.onSurfaceVariant : theme.disabledColor,
|
||||
),
|
||||
enabled: canOpen,
|
||||
),
|
||||
child: displayLabel == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
displayLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -110,25 +193,28 @@ class AppSearchableDropdown<T> extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchableDropdownSheet<T> extends StatefulWidget {
|
||||
const _SearchableDropdownSheet({
|
||||
required this.title,
|
||||
class _SearchableDropdownPanel<T> extends StatefulWidget {
|
||||
const _SearchableDropdownPanel({
|
||||
required this.maxHeight,
|
||||
required this.options,
|
||||
required this.selected,
|
||||
required this.searchHint,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final double maxHeight;
|
||||
final List<AppDropdownOption<T>> options;
|
||||
final T? selected;
|
||||
final String searchHint;
|
||||
final ValueChanged<T> onSelected;
|
||||
|
||||
@override
|
||||
State<_SearchableDropdownSheet<T>> createState() =>
|
||||
_SearchableDropdownSheetState<T>();
|
||||
State<_SearchableDropdownPanel<T>> createState() =>
|
||||
_SearchableDropdownPanelState<T>();
|
||||
}
|
||||
|
||||
class _SearchableDropdownSheetState<T> extends State<_SearchableDropdownSheet<T>> {
|
||||
class _SearchableDropdownPanelState<T>
|
||||
extends State<_SearchableDropdownPanel<T>> {
|
||||
final _searchController = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
@ -149,83 +235,76 @@ class _SearchableDropdownSheetState<T> extends State<_SearchableDropdownSheet<T>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.55;
|
||||
final filtered = _filtered;
|
||||
|
||||
return SafeArea(
|
||||
child: Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: filtered.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No options found',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final option = filtered[index];
|
||||
final isSelected = option.value == widget.selected;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
option.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
selected: isSelected,
|
||||
onTap: () => Navigator.of(context).pop(option.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onChanged: (value) => setState(() => _query = value),
|
||||
),
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: widget.maxHeight - 56),
|
||||
child: filtered.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'No options found',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
color: theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final option = filtered[index];
|
||||
final isSelected = option.value == widget.selected;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
option.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
selected: isSelected,
|
||||
onTap: () => widget.onSelected(option.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import '../routes/menu_config.dart' as menu;
|
||||
import '../utils/navigation_utils.dart';
|
||||
import 'app_sidebar.dart';
|
||||
import 'app_top_nav.dart';
|
||||
import 'theme_keyed_subtree.dart';
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
const AppShell({super.key, required this.child});
|
||||
@ -70,8 +71,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
_scaffoldKey.currentState?.closeDrawer();
|
||||
},
|
||||
),
|
||||
body: KeyedSubtree(
|
||||
key: ValueKey(currentRoute),
|
||||
body: ThemeKeyedSubtree(
|
||||
pageKey: currentRoute,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
@ -96,8 +97,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
onItemTap: (route) => goAndDismissOverlays(context, route),
|
||||
),
|
||||
Expanded(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(currentRoute),
|
||||
child: ThemeKeyedSubtree(
|
||||
pageKey: currentRoute,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
@ -125,8 +126,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(currentRoute),
|
||||
child: ThemeKeyedSubtree(
|
||||
pageKey: currentRoute,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
|
||||
30
lib/shared/widgets/theme_keyed_subtree.dart
Normal file
30
lib/shared/widgets/theme_keyed_subtree.dart
Normal file
@ -0,0 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/theme_provider.dart';
|
||||
|
||||
/// Rebuilds [child] when light/dark mode changes so cached text styles refresh.
|
||||
class ThemeKeyedSubtree extends ConsumerWidget {
|
||||
const ThemeKeyedSubtree({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.pageKey,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String? pageKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final key = pageKey == null
|
||||
? '$themeMode-$brightness'
|
||||
: '$pageKey-$themeMode-$brightness';
|
||||
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(key),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user