user & roles done

This commit is contained in:
Surendiran 2026-06-20 09:58:45 +05:30
parent 9a9f944b26
commit 0102a7405c
29 changed files with 1331 additions and 554 deletions

View File

@ -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!,

View File

@ -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);

View File

@ -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;

View File

@ -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,
),
),
),
);
}),
],
),
);
}),
],
),
),
),
],
),
);
},
);
}
}

View File

@ -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,
),
),
],

View File

@ -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,

View File

@ -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(),
);
}

View File

@ -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));
}

View File

@ -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,
);
}

View File

@ -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();
}

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../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;

View File

@ -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(),
),
);
},

View File

@ -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>(

View File

@ -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,

View File

@ -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

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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) {

View File

@ -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),
),
],
),

View File

@ -0,0 +1,9 @@
class ExportFileResult {
const ExportFileResult({
required this.bytes,
required this.fileName,
});
final List<int> bytes;
final String fileName;
}

View 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;
}

View File

@ -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());

View 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;
}

View 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);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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),
);
},
),
),
],
);
}
}

View File

@ -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,
),
),

View 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,
);
}
}