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), theme: buildLightTheme(branding),
darkTheme: buildDarkTheme(branding), darkTheme: buildDarkTheme(branding),
themeMode: resolveThemeMode(themeMode), themeMode: resolveThemeMode(themeMode),
themeAnimationDuration: Duration.zero,
routerConfig: router, routerConfig: router,
builder: (context, child) => ResponsiveBreakpoints.builder( builder: (context, child) => ResponsiveBreakpoints.builder(
child: child!, child: child!,

View File

@ -3,18 +3,22 @@ import 'package:intl/intl.dart';
class DateFormatter { class DateFormatter {
DateFormatter._(); DateFormatter._();
static final _dateFormat = DateFormat('dd MMM yyyy'); /// Application-wide display format: DD/MM/YYYY
static final _dateTimeFormat = DateFormat('dd MMM yyyy, hh:mm a'); 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 final _apiDateFormat = DateFormat('yyyy-MM-dd');
static String displayDate(DateTime? date) { static String displayDate(DateTime? date) {
if (date == null) return '-'; if (date == null) return '-';
return _dateFormat.format(date); return _dateFormat.format(date.toLocal());
} }
static String displayDateTime(DateTime? date) { static String displayDateTime(DateTime? date) {
if (date == null) return '-'; if (date == null) return '-';
return _dateTimeFormat.format(date); return _dateTimeFormat.format(date.toLocal());
} }
static String toApiDate(DateTime date) => _apiDateFormat.format(date); static String toApiDate(DateTime date) => _apiDateFormat.format(date);

View File

@ -15,7 +15,8 @@ class LoginColors {
Color get pageBackground => theme.scaffoldBackgroundColor; Color get pageBackground => theme.scaffoldBackgroundColor;
Color get cardBackground => AppColors.card; Color get cardBackground =>
isDark ? colorScheme.surface : AppColors.card;
Color get headingColor => colorScheme.onSurface; Color get headingColor => colorScheme.onSurface;

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/responsive_utils.dart'; import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_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_confirmation_dialog.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_card.dart';
@ -57,6 +60,7 @@ class _UsersRoleManagementScreenState
final saved = await showSidePanel<bool>( final saved = await showSidePanel<bool>(
context, context,
AddUserPanel(userId: userId), AddUserPanel(userId: userId),
width: 560,
); );
if (saved == true && mounted) { if (saved == true && mounted) {
ref.invalidate(usersListProvider); ref.invalidate(usersListProvider);
@ -409,7 +413,7 @@ class _UsersTab extends ConsumerStatefulWidget {
} }
class _UsersTabState extends ConsumerState<_UsersTab> { class _UsersTabState extends ConsumerState<_UsersTab> {
static const _tableMinWidth = 1040.0; static const _tableMinWidth = 1120.0;
String? _selectedRoleName; String? _selectedRoleName;
String? _selectedDepartmentName; String? _selectedDepartmentName;
@ -417,7 +421,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
String _formatLastLogin(DateTime? value) { String _formatLastLogin(DateTime? value) {
if (value == null) return ''; if (value == null) return '';
return DateFormat('MMM d, yyyy h:mm a').format(value.toLocal()); return DateFormatter.displayDateTime(value);
} }
int? _roleIdForFilter(UserFiltersModel? filters) { 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 { Future<void> _deactivateUser(ManagedUserModel user) async {
final confirmed = await showAppConfirmationDialog( final confirmed = await showAppConfirmationDialog(
context: context, context: context,
@ -575,7 +608,7 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
return AppCard( return AppCard(
enableHover: false, enableHover: false,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.none,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -598,6 +631,8 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
roles: roles, roles: roles,
departments: departments, departments: departments,
statuses: statuses, statuses: statuses,
isExporting: usersState.isExporting,
onExport: _exportUsers,
onSearch: ref.read(usersListProvider.notifier).setSearch, onSearch: ref.read(usersListProvider.notifier).setSearch,
onRoleChanged: (value) { onRoleChanged: (value) {
setState(() { setState(() {
@ -645,9 +680,12 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minWidth: tableWidth), constraints: BoxConstraints(minWidth: tableWidth),
child: DataTable( child: DataTable(
horizontalMargin: 20,
columnSpacing: 16,
headingRowColor: WidgetStateProperty.all( headingRowColor: WidgetStateProperty.all(
Theme.of(context) Theme.of(context)
.colorScheme .colorScheme
@ -662,7 +700,9 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
DataColumn(label: Text('PLANT')), DataColumn(label: Text('PLANT')),
DataColumn(label: Text('LAST LOGIN')), DataColumn(label: Text('LAST LOGIN')),
DataColumn(label: Text('STATUS')), DataColumn(label: Text('STATUS')),
DataColumn(label: Text('')), DataColumn(
label: UserTableActionsHeader(),
),
], ],
rows: usersState.users.map((user) { rows: usersState.users.map((user) {
final status = userStatusFromApi(user.status); final status = userStatusFromApi(user.status);
@ -707,11 +747,15 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
), ),
), ),
DataCell( DataCell(
UserTableActions( UserTableActionsCell(
user: user, child: UserTableActions(
onEdit: () => _editUser(user), user: user,
onResetPassword: () => _resetPassword(user), onEdit: () => _editUser(user),
onDeactivate: () => _deactivateUser(user), onResetPassword: () =>
_resetPassword(user),
onDeactivate: () =>
_deactivateUser(user),
),
), ),
), ),
], ],
@ -800,6 +844,8 @@ class _UsersFilterBar extends StatelessWidget {
required this.departments, required this.departments,
required this.statuses, required this.statuses,
required this.onSearch, required this.onSearch,
required this.onExport,
this.isExporting = false,
required this.onRoleChanged, required this.onRoleChanged,
required this.onDepartmentChanged, required this.onDepartmentChanged,
required this.onStatusChanged, required this.onStatusChanged,
@ -813,6 +859,8 @@ class _UsersFilterBar extends StatelessWidget {
final List<String> departments; final List<String> departments;
final List<String> statuses; final List<String> statuses;
final ValueChanged<String> onSearch; final ValueChanged<String> onSearch;
final VoidCallback onExport;
final bool isExporting;
final ValueChanged<String> onRoleChanged; final ValueChanged<String> onRoleChanged;
final ValueChanged<String> onDepartmentChanged; final ValueChanged<String> onDepartmentChanged;
final ValueChanged<String> onStatusChanged; final ValueChanged<String> onStatusChanged;
@ -848,9 +896,15 @@ class _UsersFilterBar extends StatelessWidget {
onChanged: onStatusChanged, onChanged: onStatusChanged,
), ),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () {}, onPressed: isExporting ? null : onExport,
icon: const Icon(Icons.download_outlined, size: 18), icon: isExporting
label: const Text('Export'), ? 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final rolesAsync = ref.watch(rolesListProvider); 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( return rolesAsync.when(
loading: () => const AppCard( loading: () => const AppCard(
@ -942,6 +999,7 @@ class _RolesTab extends ConsumerWidget {
final roles = rolesState.roles; final roles = rolesState.roles;
return GridView.builder( return GridView.builder(
key: ValueKey('$themeMode-$brightness'),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 320, maxCrossAxisExtent: 320,
mainAxisExtent: 190, mainAxisExtent: 190,
@ -1020,6 +1078,7 @@ class _RolesTab extends ConsumerWidget {
role.name, role.name,
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -1043,7 +1102,9 @@ class _RolesTab extends ConsumerWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${role.userCount} users', '${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), const SizedBox(width: 16),
Icon( Icon(
@ -1054,7 +1115,9 @@ class _RolesTab extends ConsumerWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${role.permissionCount} permissions', '${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> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(rbacProvider); final rolesAsync = ref.watch(rolesListProvider);
final role = state.selectedRole; final catalogAsync = ref.watch(permissionCatalogProvider);
final selectedRoleId = _selectedRoleId;
final matrixAsync = selectedRoleId == null
? null
: ref.watch(permissionMatrixProvider(selectedRoleId));
return AppCard( return rolesAsync.when(
enableHover: false, loading: () => const AppCard(
clipBehavior: Clip.antiAlias, enableHover: false,
elevation: 0, child: AppLoadingView(message: 'Loading roles...'),
shape: RoundedRectangleBorder( ),
borderRadius: BorderRadius.circular(12), error: (error, _) => AppCard(
side: BorderSide( enableHover: false,
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.12), child: ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(rolesListProvider),
), ),
), ),
child: Column( data: (rolesState) {
crossAxisAlignment: CrossAxisAlignment.stretch, final roles = rolesState.roles;
children: [ _ensureSelectedRole(roles);
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), RoleCardModel? selectedRole;
child: Row( for (final role in roles) {
crossAxisAlignment: CrossAxisAlignment.start, if (role.id == selectedRoleId) {
children: [ selectedRole = role;
Expanded( break;
child: Column( }
crossAxisAlignment: CrossAxisAlignment.start, }
children: [
Text( return AppCard(
'Role permission matrix', enableHover: false,
style: clipBehavior: Clip.none,
Theme.of(context).textTheme.titleMedium?.copyWith( elevation: 0,
fontWeight: FontWeight.w700, 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( ElevatedButton.icon(
'Select a role below and toggle module permissions.', onPressed: selectedRoleId == null || _isSaving
style: Theme.of(context).textTheme.bodySmall?.copyWith( ? null
color: Theme.of(context) : () => _save(selectedRole?.name ?? 'role'),
.colorScheme icon: _isSaving
.onSurfaceVariant, ? 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)),
), ),
], ],
), ),
), ...matrix.modules.asMap().entries.map((entry) {
ElevatedButton.icon( final index = entry.key;
onPressed: role == null final module = entry.value;
? null final appearance =
: () { permissionModuleAppearance(module.code, index);
ref.read(rbacProvider.notifier).savePermissions();
ScaffoldMessenger.of(context).showSnackBar( return TableRow(
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),
),
),
children: [ children: [
TableRow( Padding(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(vertical: 12),
color: Theme.of(context) child: Row(
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
),
children: [
const _MatrixHeader('MODULE'),
...RbacAction.values
.map((a) => _MatrixHeader(a.label)),
],
),
...rbacModules.map((module) {
return TableRow(
children: [ children: [
Padding( Container(
padding: width: 28,
const EdgeInsets.symmetric(vertical: 12), height: 28,
child: Row( decoration: BoxDecoration(
children: [ color: appearance.color.withValues(alpha: 0.1),
Container( borderRadius: BorderRadius.circular(6),
width: 28, ),
height: 28, child: Icon(
decoration: BoxDecoration( appearance.icon,
color: module.color size: 16,
.withValues(alpha: 0.1), color: appearance.color,
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,
),
),
],
), ),
), ),
...RbacAction.values.map((action) { const SizedBox(width: 10),
final checked = Expanded(
role.hasPermission(module.key, action); child: Text(
return Padding( module.name,
padding: style: const TextStyle(
const EdgeInsets.symmetric(vertical: 4), fontWeight: FontWeight.w500,
child: Center(
child: Checkbox(
value: checked,
onChanged: (_) => ref
.read(rbacProvider.notifier)
.togglePermission(
module.key,
action,
),
),
), ),
); ),
}), ),
], ],
),
),
...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; final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit user' : 'Add new user', title: widget.isEditing ? 'Edit user' : 'Add user',
footer: Row( footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Expanded( OutlinedButton(
child: OutlinedButton( onPressed: isSubmitting
onPressed: isSubmitting ? null
? null : () => Navigator.of(context, rootNavigator: true).pop(),
: () => Navigator.of(context, rootNavigator: true).pop(), child: const Text('Cancel'),
child: const Text('Cancel'),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( AppButton(
child: AppButton( label: widget.isEditing ? 'Update user' : 'Save user',
label: widget.isEditing ? 'Update user' : 'Save user', expand: false,
expand: true, icon: Icons.check,
isLoading: isSubmitting, isLoading: isSubmitting,
onPressed: isSubmitting ? null : _save, onPressed: isSubmitting ? null : _save,
),
), ),
], ],
), ),
@ -236,92 +234,95 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SidePanelSection( SidePanelFormRow(
title: 'PERSONAL DETAILS', left: AppTextField(
children: [ controller: _nameController,
AppTextField( label: 'Full name *',
controller: _nameController, hint: 'e.g. Ravi Kumar',
label: 'Full name *', validator: (v) => Validators.required(v, fieldName: 'Name'),
hint: 'e.g. Ravi Kumar', ),
validator: (v) => Validators.required(v, fieldName: 'Name'), right: AppTextField(
), controller: _employeeCodeController,
const SizedBox(height: 12), label: 'Employee code *',
AppTextField( hint: 'e.g. EMP002',
controller: _employeeCodeController, validator: (v) =>
label: 'Employee code *', Validators.required(v, fieldName: '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: _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( SidePanelSection(
title: 'ROLE & ACCESS', title: 'ROLE & ACCESS',
children: [ children: [
_buildDropdown( SidePanelFormRow(
label: 'Role', left: _buildDropdown(
value: _selectedRoleId, label: 'Role',
options: formState.roles, value: _selectedRoleId,
required: true, options: formState.roles,
onChanged: (v) => setState(() => _selectedRoleId = v), required: true,
), onChanged: (v) => setState(() => _selectedRoleId = v),
const SizedBox(height: 12), ),
AppSearchableDropdown<String>( right: AppSearchableDropdown<String>(
label: 'Status', label: 'Status',
value: _selectedStatus, value: _selectedStatus,
options: _statusOptions, options: _statusOptions,
searchHint: 'Search status...', searchHint: 'Search status...',
onChanged: (v) => setState(() => _selectedStatus = v ?? 'Active'), onChanged: (v) =>
setState(() => _selectedStatus = v ?? 'Active'),
),
), ),
], ],
), ),
SidePanelSection( SidePanelSection(
title: 'ORGANISATION', title: 'ORGANISATION',
children: [ children: [
_buildDropdown( SidePanelFormRow(
label: 'Department', left: _buildDropdown(
value: _selectedDepartmentId, label: 'Department',
options: formState.departments, value: _selectedDepartmentId,
onChanged: (v) => setState(() => _selectedDepartmentId = v), 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), SidePanelFormRow(
_buildDropdown( left: _buildDropdown(
label: 'Designation', label: 'Plant / Unit',
value: _selectedDesignationId, value: _selectedPlantId,
options: formState.designations, options: formState.plants,
onChanged: (v) => setState(() => _selectedDesignationId = v), onChanged: (v) => setState(() => _selectedPlantId = v),
), ),
const SizedBox(height: 12), right: _buildDropdown(
_buildDropdown( label: 'Reporting to',
label: 'Plant / Unit', value: _selectedReportingToId,
value: _selectedPlantId, options: formState.managers,
options: formState.plants, hint: formState.managers.isEmpty
onChanged: (v) => setState(() => _selectedPlantId = v), ? 'No managers available'
), : 'Select manager',
const SizedBox(height: 12), onChanged: (v) =>
_buildDropdown( setState(() => _selectedReportingToId = v),
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 ? null
: (v) => Validators.required(v, fieldName: 'Password'), : (v) => Validators.required(v, fieldName: 'Password'),
), ),
const SizedBox(height: 4),
Text( Text(
widget.isEditing widget.isEditing
? 'Leave blank to keep the current password.' ? 'Leave blank to keep the current password.'
: 'User will be asked to change this on first login.', : 'User will be asked to change this on first login.',
style: Theme.of(context).textTheme.bodySmall?.copyWith( 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, required this.onDeactivate,
}); });
static const columnWidth = 120.0;
final ManagedUserModel user; final ManagedUserModel user;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onResetPassword; final VoidCallback onResetPassword;
@ -204,29 +206,24 @@ class UserTableActions extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
IconButton( _UserActionIcon(
tooltip: 'Edit user', tooltip: 'Edit user',
icon: Icon(Icons.edit_outlined, size: 18, color: muted), icon: Icons.edit_outlined,
visualDensity: VisualDensity.compact, color: muted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: onEdit, onPressed: onEdit,
), ),
IconButton( _UserActionIcon(
tooltip: 'Reset password', tooltip: 'Reset password',
icon: Icon(Icons.vpn_key_outlined, size: 18, color: muted), icon: Icons.vpn_key_outlined,
visualDensity: VisualDensity.compact, color: muted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: onResetPassword, onPressed: onResetPassword,
), ),
IconButton( _UserActionIcon(
tooltip: 'Deactivate user', tooltip: 'Deactivate user',
icon: Icon(Icons.person_off_outlined, size: 18, color: muted), icon: Icons.person_off_outlined,
visualDensity: VisualDensity.compact, color: muted,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: onDeactivate, 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 { class RolePill extends StatelessWidget {
const RolePill({ const RolePill({
super.key, super.key,
@ -280,9 +341,16 @@ class RolePill extends StatelessWidget {
} }
} }
Future<T?> showSidePanel<T>(BuildContext context, Widget panel) { Future<T?> showSidePanel<T>(
final width = MediaQuery.sizeOf(context).width; BuildContext context,
final panelWidth = width > 1200 ? 480.0 : (width * 0.38).clamp(360.0, 480.0); 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>( return showGeneralDialog<T>(
context: context, context: context,
@ -386,6 +454,9 @@ class SidePanelSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dividerColor =
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -397,7 +468,9 @@ class SidePanelSection extends StatelessWidget {
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Divider(height: 1, color: dividerColor),
const SizedBox(height: 16),
...children, ...children,
const SizedBox(height: 24), 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 { class ModulePermissionRow extends StatelessWidget {
const ModulePermissionRow({ const ModulePermissionRow({
super.key, super.key,

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/role_model.dart'; import '../../../../shared/models/role_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
@ -72,23 +73,28 @@ class RoleRemoteDataSource {
await dio.delete(ApiEndpoints.roleById(id)); await dio.delete(ApiEndpoints.roleById(id));
} }
Future<List<PermissionCatalogModel>> getPermissionCatalog() async { Future<List<PermissionModuleCatalog>> getPermissionCatalog() async {
final response = await dio.get(ApiEndpoints.rolesPermissions); 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)); 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( Future<void> savePermissionMatrix(
String roleId, String roleId,
PermissionMatrixSaveRequest request, RolePermissionMatrix matrix,
) async { ) async {
await dio.put( await dio.put(
ApiEndpoints.rolePermissionMatrix(roleId), 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/api_handler.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/role_model.dart'; import '../../../../shared/models/role_model.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../domain/repositories/role_repository.dart'; import '../../domain/repositories/role_repository.dart';
import '../datasources/role_remote_data_source.dart'; import '../datasources/role_remote_data_source.dart';
@ -49,17 +50,17 @@ class RoleRepositoryImpl implements RoleRepository {
safeApiCall(() => remote.deleteRole(id)); safeApiCall(() => remote.deleteRole(id));
@override @override
Future<Result<List<PermissionCatalogModel>>> getPermissionCatalog() => Future<Result<List<PermissionModuleCatalog>>> getPermissionCatalog() =>
safeApiCall(remote.getPermissionCatalog); safeApiCall(remote.getPermissionCatalog);
@override @override
Future<Result<PermissionMatrixModel>> getPermissionMatrix(String roleId) => Future<Result<RolePermissionMatrix>> getPermissionMatrix(String roleId) =>
safeApiCall(() => remote.getPermissionMatrix(roleId)); safeApiCall(() => remote.getPermissionMatrix(roleId));
@override @override
Future<Result<void>> savePermissionMatrix( Future<Result<void>> savePermissionMatrix(
String roleId, 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 '../../../../core/network/api_handler.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/role_model.dart'; import '../../../../shared/models/role_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
@ -10,10 +11,10 @@ abstract class RoleRepository {
Future<Result<RoleModel>> createRole(CreateRoleRequest request); Future<Result<RoleModel>> createRole(CreateRoleRequest request);
Future<Result<RoleModel>> updateRole(String id, UpdateRoleRequest request); Future<Result<RoleModel>> updateRole(String id, UpdateRoleRequest request);
Future<Result<void>> deleteRole(String id); Future<Result<void>> deleteRole(String id);
Future<Result<List<PermissionCatalogModel>>> getPermissionCatalog(); Future<Result<List<PermissionModuleCatalog>>> getPermissionCatalog();
Future<Result<PermissionMatrixModel>> getPermissionMatrix(String roleId); Future<Result<RolePermissionMatrix>> getPermissionMatrix(String roleId);
Future<Result<void>> savePermissionMatrix( Future<Result<void>> savePermissionMatrix(
String roleId, String roleId,
PermissionMatrixSaveRequest request, RolePermissionMatrix matrix,
); );
} }

View File

@ -1,4 +1,5 @@
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/role_model.dart'; import '../../../../shared/models/role_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../repositories/role_repository.dart'; import '../repositories/role_repository.dart';
@ -25,7 +26,7 @@ class GetPermissionMatrixUseCase {
final RoleRepository _repository; final RoleRepository _repository;
Future<Result<PermissionMatrixModel>> call(String roleId) => Future<Result<RolePermissionMatrix>> call(String roleId) =>
_repository.getPermissionMatrix(roleId); _repository.getPermissionMatrix(roleId);
} }
@ -34,8 +35,8 @@ class SavePermissionMatrixUseCase {
final RoleRepository _repository; final RoleRepository _repository;
Future<Result<void>> call(String roleId, PermissionMatrixSaveRequest request) => Future<Result<void>> call(String roleId, RolePermissionMatrix matrix) =>
_repository.savePermissionMatrix(roleId, request); _repository.savePermissionMatrix(roleId, matrix);
} }
class CreateRoleUseCase { class CreateRoleUseCase {
@ -69,6 +70,6 @@ class GetPermissionCatalogUseCase {
final RoleRepository _repository; final RoleRepository _repository;
Future<Result<List<PermissionCatalogModel>>> call() => Future<Result<List<PermissionModuleCatalog>>> call() =>
_repository.getPermissionCatalog(); _repository.getPermissionCatalog();
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../data/repositories/role_repository_impl.dart'; import '../../data/repositories/role_repository_impl.dart';
import '../../domain/usecases/role_usecases.dart'; import '../../domain/usecases/role_usecases.dart';
@ -44,6 +45,10 @@ final deleteRoleUseCaseProvider = Provider((ref) {
return DeleteRoleUseCase(ref.watch(roleRepositoryProvider)); return DeleteRoleUseCase(ref.watch(roleRepositoryProvider));
}); });
final getPermissionCatalogUseCaseProvider = Provider((ref) {
return GetPermissionCatalogUseCase(ref.watch(roleRepositoryProvider));
});
final getPermissionMatrixUseCaseProvider = Provider((ref) { final getPermissionMatrixUseCaseProvider = Provider((ref) {
return GetPermissionMatrixUseCase(ref.watch(roleRepositoryProvider)); return GetPermissionMatrixUseCase(ref.watch(roleRepositoryProvider));
}); });
@ -52,6 +57,13 @@ final savePermissionMatrixUseCaseProvider = Provider((ref) {
return SavePermissionMatrixUseCase(ref.watch(roleRepositoryProvider)); 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 = final rolesListProvider =
AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new); AsyncNotifierProvider<RolesListNotifier, RolesListState>(RolesListNotifier.new);
@ -93,12 +105,13 @@ class RolesListNotifier extends AsyncNotifier<RolesListState> {
final permissionMatrixProvider = AsyncNotifierProvider.family< final permissionMatrixProvider = AsyncNotifierProvider.family<
PermissionMatrixNotifier, PermissionMatrixNotifier,
PermissionMatrixModel, RolePermissionMatrix,
String>(PermissionMatrixNotifier.new); String>(PermissionMatrixNotifier.new);
class PermissionMatrixNotifier extends FamilyAsyncNotifier<PermissionMatrixModel, String> { class PermissionMatrixNotifier
extends FamilyAsyncNotifier<RolePermissionMatrix, String> {
@override @override
Future<PermissionMatrixModel> build(String arg) async { Future<RolePermissionMatrix> build(String arg) async {
final useCase = ref.read(getPermissionMatrixUseCaseProvider); final useCase = ref.read(getPermissionMatrixUseCaseProvider);
final result = await useCase(arg); final result = await useCase(arg);
if (result.failure != null) throw result.failure!; if (result.failure != null) throw result.failure!;
@ -109,20 +122,14 @@ class PermissionMatrixNotifier extends FamilyAsyncNotifier<PermissionMatrixModel
final current = state.valueOrNull; final current = state.valueOrNull;
if (current == null) return; if (current == null) return;
final updated = current.matrix.map((row) { final updated = current.modules.map((row) {
if (row.moduleId != moduleId) return row; if (row.moduleId != moduleId) return row;
final actions = row.actions; final granted = Map<String, bool>.from(row.granted);
final next = switch (action) { granted[action] = value;
'view' => actions.copyWith(view: value), return row.copyWith(granted: granted);
'edit' => actions.copyWith(edit: value),
'approve' => actions.copyWith(approve: value),
'export' => actions.copyWith(export: value),
_ => actions,
};
return row.copyWith(actions: next);
}).toList(); }).toList();
state = AsyncData(current.copyWith(matrix: updated)); state = AsyncData(current.copyWith(modules: updated));
} }
Future<bool> save() async { Future<bool> save() async {
@ -130,17 +137,7 @@ class PermissionMatrixNotifier extends FamilyAsyncNotifier<PermissionMatrixModel
if (current == null) return false; if (current == null) return false;
final useCase = ref.read(savePermissionMatrixUseCaseProvider); final useCase = ref.read(savePermissionMatrixUseCaseProvider);
final request = PermissionMatrixSaveRequest( final result = await useCase(arg, current);
matrix: current.matrix
.map(
(row) => PermissionMatrixSaveRow(
moduleId: row.moduleId,
actions: row.actions,
),
)
.toList(),
);
final result = await useCase(arg, request);
if (result.failure != null) return false; if (result.failure != null) return false;
ref.invalidateSelf(); ref.invalidateSelf();
await future; await future;

View File

@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/utils/responsive_utils.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_button.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -80,7 +80,7 @@ class _MatrixGrid extends ConsumerWidget {
const _MatrixGrid({required this.roleId, required this.matrix}); const _MatrixGrid({required this.roleId, required this.matrix});
final String roleId; final String roleId;
final PermissionMatrixModel matrix; final RolePermissionMatrix matrix;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -88,22 +88,28 @@ class _MatrixGrid extends ConsumerWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
columns: const [ columns: [
DataColumn(label: Text('Module')), const DataColumn(label: Text('Module')),
DataColumn(label: Text('View')), ...matrix.actionColumns.map(
DataColumn(label: Text('Edit')), (action) => DataColumn(label: Text(permissionActionLabel(action))),
DataColumn(label: Text('Approve')), ),
DataColumn(label: Text('Export')),
], ],
rows: matrix.matrix rows: matrix.modules
.map( .map(
(row) => DataRow( (row) => DataRow(
cells: [ cells: [
DataCell(Text(row.module)), DataCell(Text(row.name)),
DataCell(_actionToggle(roleId, row, 'view', row.actions.view, ref)), ...matrix.actionColumns.map(
DataCell(_actionToggle(roleId, row, 'edit', row.actions.edit, ref)), (action) => DataCell(
DataCell(_actionToggle(roleId, row, 'approve', row.actions.approve, ref)), _actionToggle(
DataCell(_actionToggle(roleId, row, 'export', row.actions.export, ref)), roleId,
row,
action,
row.granted[action] ?? false,
ref,
),
),
),
], ],
), ),
) )
@ -115,7 +121,7 @@ class _MatrixGrid extends ConsumerWidget {
Widget _actionToggle( Widget _actionToggle(
String roleId, String roleId,
PermissionMatrixRow row, PermissionMatrixModuleRow row,
String action, String action,
bool value, bool value,
WidgetRef ref, WidgetRef ref,
@ -133,48 +139,29 @@ class _MatrixCardList extends ConsumerWidget {
const _MatrixCardList({required this.roleId, required this.matrix}); const _MatrixCardList({required this.roleId, required this.matrix});
final String roleId; final String roleId;
final PermissionMatrixModel matrix; final RolePermissionMatrix matrix;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ListView.separated( return ListView.separated(
itemCount: matrix.matrix.length, itemCount: matrix.modules.length,
separatorBuilder: (_, __) => const SizedBox(height: 12), separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final row = matrix.matrix[index]; final row = matrix.modules[index];
return AppCard( return AppCard(
child: ExpansionTile( child: ExpansionTile(
title: Text(row.module), title: Text(row.name),
children: [ children: matrix.actionColumns
_PermissionSwitch( .map(
label: 'View', (action) => _PermissionSwitch(
value: row.actions.view, label: permissionActionLabel(action),
onChanged: (v) => ref value: row.granted[action] ?? false,
.read(permissionMatrixProvider(roleId).notifier) onChanged: (v) => ref
.toggleAction(row.moduleId, 'view', v), .read(permissionMatrixProvider(roleId).notifier)
), .toggleAction(row.moduleId, action, v),
_PermissionSwitch( ),
label: 'Edit (Add & Edit)', )
value: row.actions.edit, .toList(),
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),
),
],
), ),
); );
}, },

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_text_field.dart';
import '../../domain/entities/app_settings.dart'; import '../../domain/entities/app_settings.dart';
@ -22,7 +23,6 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
late String _timeZone; late String _timeZone;
late String _language; late String _language;
late String _currency; late String _currency;
late String _dateFormat;
late String _timeFormat; late String _timeFormat;
late String _numberFormat; late String _numberFormat;
@ -36,7 +36,6 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
_timeZone = general.timeZone; _timeZone = general.timeZone;
_language = general.language; _language = general.language;
_currency = general.currency; _currency = general.currency;
_dateFormat = general.dateFormat;
_timeFormat = general.timeFormat; _timeFormat = general.timeFormat;
_numberFormat = general.numberFormat; _numberFormat = general.numberFormat;
} }
@ -58,7 +57,7 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
timeZone: _timeZone, timeZone: _timeZone,
language: _language, language: _language,
currency: _currency, currency: _currency,
dateFormat: _dateFormat, dateFormat: DateFormatter.displayDatePattern,
timeFormat: _timeFormat, timeFormat: _timeFormat,
numberFormat: _numberFormat, numberFormat: _numberFormat,
), ),
@ -135,12 +134,15 @@ class _GeneralSettingsScreenState extends ConsumerState<GeneralSettingsScreen> {
onChanged: (v) => setState(() => _currency = v!), onChanged: (v) => setState(() => _currency = v!),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SettingsDropdownField<String>( InputDecorator(
label: 'Date Format', decoration: const InputDecoration(
value: _dateFormat, labelText: 'Date Format',
items: const ['dd/MM/yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd'], enabled: false,
itemLabel: (v) => v, ),
onChanged: (v) => setState(() => _dateFormat = v!), child: Text(
'DD/MM/YYYY',
style: Theme.of(context).textTheme.bodyLarge,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SettingsDropdownField<String>( SettingsDropdownField<String>(

View File

@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
class UserRemoteDataSource { class UserRemoteDataSource {
@ -126,13 +127,17 @@ class UserRemoteDataSource {
await dio.delete(ApiEndpoints.userById(id)); 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>>( final response = await dio.get<List<int>>(
ApiEndpoints.usersExport, ApiEndpoints.usersExport,
queryParameters: _queryToMap(query), queryParameters: _exportQueryToMap(query),
options: Options(responseType: ResponseType.bytes), 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 { Future<ManagedUserModel> updateProfile(UpdateProfileRequest request) async {
@ -143,6 +148,43 @@ class UserRemoteDataSource {
return ManagedUserModel.fromJson(response.data['data'] as Map<String, dynamic>); 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) { Map<String, dynamic> _queryToMap(UserListQuery query) {
return { return {
'page': query.page, '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/api_handler.dart';
import '../../../../core/network/dio_client.dart'; import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../../domain/repositories/user_repository.dart'; import '../../domain/repositories/user_repository.dart';
import '../datasources/user_remote_data_source.dart'; import '../datasources/user_remote_data_source.dart';
@ -54,7 +55,7 @@ class UserRepositoryImpl implements UserRepository {
safeApiCall(() => remote.deleteUser(id)); safeApiCall(() => remote.deleteUser(id));
@override @override
Future<Result<List<int>>> exportUsers(UserListQuery query) => Future<Result<ExportFileResult>> exportUsers(UserListQuery query) =>
safeApiCall(() => remote.exportUsers(query)); safeApiCall(() => remote.exportUsers(query));
@override @override

View File

@ -1,5 +1,6 @@
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
abstract class UserRepository { abstract class UserRepository {
@ -10,6 +11,6 @@ abstract class UserRepository {
Future<Result<ManagedUserModel>> createUser(CreateUserRequest request); Future<Result<ManagedUserModel>> createUser(CreateUserRequest request);
Future<Result<ManagedUserModel>> updateUser(String id, UpdateUserRequest request); Future<Result<ManagedUserModel>> updateUser(String id, UpdateUserRequest request);
Future<Result<void>> deleteUser(String id); 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); Future<Result<ManagedUserModel>> updateProfile(UpdateProfileRequest request);
} }

View File

@ -1,5 +1,6 @@
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/api_response.dart'; import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
import '../repositories/user_repository.dart'; import '../repositories/user_repository.dart';
@ -70,3 +71,12 @@ class UpdateProfileUseCase {
Future<Result<ManagedUserModel>> call(UpdateProfileRequest request) => Future<Result<ManagedUserModel>> call(UpdateProfileRequest request) =>
_repository.updateProfile(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 '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 '../../../../shared/models/user_management_models.dart';
import '../../data/repositories/user_repository_impl.dart'; import '../../data/repositories/user_repository_impl.dart';
import '../../domain/usecases/user_usecases.dart'; import '../../domain/usecases/user_usecases.dart';
@ -14,6 +14,7 @@ class UsersListState {
this.total = 0, this.total = 0,
this.totalPages = 1, this.totalPages = 1,
this.isRefreshing = false, this.isRefreshing = false,
this.isExporting = false,
this.actionError, this.actionError,
this.actionSuccess, this.actionSuccess,
}); });
@ -25,6 +26,7 @@ class UsersListState {
final int total; final int total;
final int totalPages; final int totalPages;
final bool isRefreshing; final bool isRefreshing;
final bool isExporting;
final String? actionError; final String? actionError;
final String? actionSuccess; final String? actionSuccess;
@ -36,6 +38,7 @@ class UsersListState {
int? total, int? total,
int? totalPages, int? totalPages,
bool? isRefreshing, bool? isRefreshing,
bool? isExporting,
String? actionError, String? actionError,
String? actionSuccess, String? actionSuccess,
bool clearMessages = false, bool clearMessages = false,
@ -48,6 +51,7 @@ class UsersListState {
total: total ?? this.total, total: total ?? this.total,
totalPages: totalPages ?? this.totalPages, totalPages: totalPages ?? this.totalPages,
isRefreshing: isRefreshing ?? this.isRefreshing, isRefreshing: isRefreshing ?? this.isRefreshing,
isExporting: isExporting ?? this.isExporting,
actionError: clearMessages ? null : actionError ?? this.actionError, actionError: clearMessages ? null : actionError ?? this.actionError,
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
); );
@ -86,6 +90,10 @@ final getUserByIdUseCaseProvider = Provider((ref) {
return GetUserByIdUseCase(ref.watch(userRepositoryProvider)); return GetUserByIdUseCase(ref.watch(userRepositoryProvider));
}); });
final exportUsersUseCaseProvider = Provider((ref) {
return ExportUsersUseCase(ref.watch(userRepositoryProvider));
});
final usersListProvider = final usersListProvider =
AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new); AsyncNotifierProvider<UsersListNotifier, UsersListState>(UsersListNotifier.new);
@ -174,6 +182,29 @@ class UsersListNotifier extends AsyncNotifier<UsersListState> {
applyQuery(current.query.copyWith(departmentId: departmentId, page: 1)); 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 { Future<bool> deactivateUser(String id) async {
final result = await ref.read(deleteUserUseCaseProvider)(id); final result = await ref.read(deleteUserUseCaseProvider)(id);
if (result.failure != null) { if (result.failure != null) {

View File

@ -2,7 +2,7 @@ import '../../../../shared/widgets/app_card.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_button.dart';
@ -69,12 +69,12 @@ class UserDetailScreen extends ConsumerWidget {
if (user.createdAt != null) if (user.createdAt != null)
_DetailRow( _DetailRow(
label: 'Created', label: 'Created',
value: DateFormat.yMMMd().add_jm().format(user.createdAt!), value: DateFormatter.displayDateTime(user.createdAt),
), ),
if (user.updatedAt != null) if (user.updatedAt != null)
_DetailRow( _DetailRow(
label: 'Updated', 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 '../../modules/settings/presentation/screens/settings_screen.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../widgets/theme_keyed_subtree.dart';
final routerProvider = Provider<GoRouter>((ref) { final routerProvider = Provider<GoRouter>((ref) {
final refreshListenable = _AuthListenable(ref); final refreshListenable = _AuthListenable(ref);
@ -88,26 +89,29 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: RouteConstants.login, path: RouteConstants.login,
builder: (context, state) => const LoginScreen(), builder: (context, state) => _themedRoute(state, const LoginScreen()),
), ),
GoRoute( GoRoute(
path: RouteConstants.forgotPassword, path: RouteConstants.forgotPassword,
builder: (context, state) => const ForgotPasswordScreen(), builder: (context, state) =>
_themedRoute(state, const ForgotPasswordScreen()),
), ),
GoRoute( GoRoute(
path: RouteConstants.resetPassword, path: RouteConstants.resetPassword,
builder: (context, state) => const ResetPasswordScreen(), builder: (context, state) =>
_themedRoute(state, const ResetPasswordScreen()),
), ),
GoRoute( GoRoute(
path: RouteConstants.verifyOtp, path: RouteConstants.verifyOtp,
builder: (context, state) { builder: (context, state) {
final email = state.uri.queryParameters['email'] ?? ''; final email = state.uri.queryParameters['email'] ?? '';
return VerifyOtpScreen(email: email); return _themedRoute(state, VerifyOtpScreen(email: email));
}, },
), ),
GoRoute( GoRoute(
path: RouteConstants.changePassword, path: RouteConstants.changePassword,
builder: (context, state) => const ChangePasswordScreen(), builder: (context, state) =>
_themedRoute(state, const ChangePasswordScreen()),
), ),
ShellRoute( ShellRoute(
builder: (context, state, child) => AppShell(child: child), builder: (context, state, child) => AppShell(child: child),
@ -308,6 +312,13 @@ final routerProvider = Provider<GoRouter>((ref) {
return router; return router;
}); });
Widget _themedRoute(GoRouterState state, Widget child) {
return ThemeKeyedSubtree(
pageKey: state.matchedLocation,
child: child,
);
}
class _AuthListenable extends ChangeNotifier { class _AuthListenable extends ChangeNotifier {
_AuthListenable(this._ref) { _AuthListenable(this._ref) {
_ref.listen(authStateProvider, (_, __) => notifyListeners()); _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> { class _AppCardState extends State<AppCard> {
bool _hovered = false; bool _hovered = false;
Brightness? _brightness;
void _setHovered(bool value) { void _setHovered(bool value) {
if (!widget.enableHover || _hovered == value) return; if (!widget.enableHover || _hovered == value) return;
setState(() => _hovered = value); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);

View File

@ -37,12 +37,24 @@ class AppHoverEffect extends StatefulWidget {
class _AppHoverEffectState extends State<AppHoverEffect> { class _AppHoverEffectState extends State<AppHoverEffect> {
bool _hovered = false; bool _hovered = false;
Brightness? _brightness;
void _setHovered(bool value) { void _setHovered(bool value) {
if (!widget.enabled || _hovered == value) return; if (!widget.enabled || _hovered == value) return;
setState(() => _hovered = value); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'app_dropdown.dart'; import 'app_dropdown.dart';
/// Dropdown that opens a searchable bottom sheet to pick an option. /// Dropdown that opens a searchable popup anchored to the field.
class AppSearchableDropdown<T> extends StatelessWidget { class AppSearchableDropdown<T> extends StatefulWidget {
const AppSearchableDropdown({ const AppSearchableDropdown({
super.key, super.key,
required this.label, required this.label,
@ -27,82 +27,165 @@ class AppSearchableDropdown<T> extends StatelessWidget {
final bool enabled; final bool enabled;
final bool isDense; 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) { String? _labelForValue(T? selected) {
if (selected == null) return null; if (selected == null) return null;
for (final option in options) { for (final option in widget.options) {
if (option.value == selected) return option.label; if (option.value == selected) return option.label;
} }
return null; return null;
} }
Future<void> _openPicker(BuildContext context, FormFieldState<T> field) async { void _removeOverlay() {
if (!enabled || options.isEmpty) return; 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>( final renderBox =
context: context, _fieldKey.currentContext?.findRenderObject() as RenderBox?;
isScrollControlled: true, if (renderBox == null) return;
showDragHandle: true,
backgroundColor: theme.bottomSheetTheme.backgroundColor ?? final fieldSize = renderBox.size;
theme.colorScheme.surface, final fieldTopLeft = renderBox.localToGlobal(Offset.zero);
builder: (context) => Theme( final screenSize = MediaQuery.sizeOf(context);
data: theme, final viewInsets = MediaQuery.viewInsetsOf(context);
child: _SearchableDropdownSheet<T>(
title: label, final spaceBelow =
options: options, screenSize.height - viewInsets.bottom - fieldTopLeft.dy - fieldSize.height;
selected: value, final spaceAbove = fieldTopLeft.dy - viewInsets.top;
searchHint: searchHint, 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; Overlay.of(context).insert(_overlayEntry!);
field.didChange(selected); setState(() {});
onChanged(selected);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final displayLabel = _labelForValue(value); final displayLabel = _labelForValue(widget.value);
return FormField<T>( return FormField<T>(
initialValue: value, initialValue: widget.value,
validator: validator, validator: widget.validator,
builder: (field) { builder: (field) {
final effectiveHint = hint ?? 'Select ${label.toLowerCase()}'; final effectiveHint = widget.hint ?? 'Select ${widget.label.toLowerCase()}';
final canOpen = enabled && options.isNotEmpty; final canOpen = widget.enabled && widget.options.isNotEmpty;
final colors = theme.colorScheme; final colors = theme.colorScheme;
return InkWell( return CompositedTransformTarget(
onTap: canOpen ? () => _openPicker(context, field) : null, link: _layerLink,
borderRadius: BorderRadius.circular(8), child: KeyedSubtree(
child: InputDecorator( key: _fieldKey,
isFocused: false, child: InkWell(
isEmpty: displayLabel == null, onTap: canOpen ? () => _openPicker(field) : null,
decoration: InputDecoration( borderRadius: BorderRadius.circular(8),
labelText: label, child: InputDecorator(
hintText: displayLabel == null ? effectiveHint : null, isFocused: _overlayEntry != null,
floatingLabelBehavior: FloatingLabelBehavior.always, isEmpty: displayLabel == null,
isDense: isDense, decoration: InputDecoration(
errorText: field.errorText, labelText: widget.label,
suffixIcon: Icon( hintText: displayLabel == null ? effectiveHint : null,
Icons.arrow_drop_down, floatingLabelBehavior: FloatingLabelBehavior.always,
color: canOpen ? colors.onSurfaceVariant : theme.disabledColor, isDense: widget.isDense,
), errorText: field.errorText,
enabled: canOpen, suffixIcon: Icon(
), _overlayEntry != null
child: displayLabel == null ? Icons.arrow_drop_up
? const SizedBox.shrink() : Icons.arrow_drop_down,
: Text( color:
displayLabel, canOpen ? colors.onSurfaceVariant : theme.disabledColor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurface,
),
), ),
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 { class _SearchableDropdownPanel<T> extends StatefulWidget {
const _SearchableDropdownSheet({ const _SearchableDropdownPanel({
required this.title, required this.maxHeight,
required this.options, required this.options,
required this.selected, required this.selected,
required this.searchHint, required this.searchHint,
required this.onSelected,
}); });
final String title; final double maxHeight;
final List<AppDropdownOption<T>> options; final List<AppDropdownOption<T>> options;
final T? selected; final T? selected;
final String searchHint; final String searchHint;
final ValueChanged<T> onSelected;
@override @override
State<_SearchableDropdownSheet<T>> createState() => State<_SearchableDropdownPanel<T>> createState() =>
_SearchableDropdownSheetState<T>(); _SearchableDropdownPanelState<T>();
} }
class _SearchableDropdownSheetState<T> extends State<_SearchableDropdownSheet<T>> { class _SearchableDropdownPanelState<T>
extends State<_SearchableDropdownPanel<T>> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
String _query = ''; String _query = '';
@ -149,83 +235,76 @@ class _SearchableDropdownSheetState<T> extends State<_SearchableDropdownSheet<T>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final maxHeight = MediaQuery.sizeOf(context).height * 0.55;
final filtered = _filtered; final filtered = _filtered;
return SafeArea( return Column(
child: Material( mainAxisSize: MainAxisSize.min,
color: theme.colorScheme.surface, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Padding( children: [
padding: EdgeInsets.only( Padding(
bottom: MediaQuery.viewInsetsOf(context).bottom, padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
), child: TextField(
child: Column( controller: _searchController,
mainAxisSize: MainAxisSize.min, autofocus: true,
crossAxisAlignment: CrossAxisAlignment.stretch, decoration: InputDecoration(
children: [ hintText: widget.searchHint,
Padding( prefixIcon: const Icon(Icons.search, size: 20),
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), isDense: true,
child: Text( contentPadding: const EdgeInsets.symmetric(
widget.title, horizontal: 12,
style: theme.textTheme.titleMedium?.copyWith( vertical: 10,
fontWeight: FontWeight.w600,
),
),
), ),
Padding( ),
padding: const EdgeInsets.all(16), onChanged: (value) => setState(() => _query = value),
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),
);
},
),
),
],
), ),
), ),
), 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 '../utils/navigation_utils.dart';
import 'app_sidebar.dart'; import 'app_sidebar.dart';
import 'app_top_nav.dart'; import 'app_top_nav.dart';
import 'theme_keyed_subtree.dart';
class AppShell extends ConsumerStatefulWidget { class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key, required this.child}); const AppShell({super.key, required this.child});
@ -70,8 +71,8 @@ class _AppShellState extends ConsumerState<AppShell> {
_scaffoldKey.currentState?.closeDrawer(); _scaffoldKey.currentState?.closeDrawer();
}, },
), ),
body: KeyedSubtree( body: ThemeKeyedSubtree(
key: ValueKey(currentRoute), pageKey: currentRoute,
child: widget.child, child: widget.child,
), ),
); );
@ -96,8 +97,8 @@ class _AppShellState extends ConsumerState<AppShell> {
onItemTap: (route) => goAndDismissOverlays(context, route), onItemTap: (route) => goAndDismissOverlays(context, route),
), ),
Expanded( Expanded(
child: KeyedSubtree( child: ThemeKeyedSubtree(
key: ValueKey(currentRoute), pageKey: currentRoute,
child: widget.child, child: widget.child,
), ),
), ),
@ -125,8 +126,8 @@ class _AppShellState extends ConsumerState<AppShell> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: KeyedSubtree( child: ThemeKeyedSubtree(
key: ValueKey(currentRoute), pageKey: currentRoute,
child: widget.child, 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,
);
}
}