user & roles done
This commit is contained in:
parent
9a9f944b26
commit
0102a7405c
@ -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!,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
9
lib/shared/models/export_file_result.dart
Normal file
9
lib/shared/models/export_file_result.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
class ExportFileResult {
|
||||||
|
const ExportFileResult({
|
||||||
|
required this.bytes,
|
||||||
|
required this.fileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<int> bytes;
|
||||||
|
final String fileName;
|
||||||
|
}
|
||||||
191
lib/shared/models/permission_matrix_models.dart
Normal file
191
lib/shared/models/permission_matrix_models.dart
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Column actions returned by `GET /roles/{id}/permission-matrix`.
|
||||||
|
const permissionMatrixActionOrder = [
|
||||||
|
'view',
|
||||||
|
'create',
|
||||||
|
'edit',
|
||||||
|
'delete',
|
||||||
|
'approve',
|
||||||
|
'export',
|
||||||
|
];
|
||||||
|
|
||||||
|
String permissionActionLabel(String action) => switch (action) {
|
||||||
|
'view' => 'VIEW',
|
||||||
|
'create' => 'CREATE',
|
||||||
|
'edit' => 'EDIT',
|
||||||
|
'delete' => 'DELETE',
|
||||||
|
'approve' => 'APPROVE',
|
||||||
|
'export' => 'EXPORT',
|
||||||
|
_ => action.toUpperCase(),
|
||||||
|
};
|
||||||
|
|
||||||
|
List<String> sortPermissionActions(Iterable<String> actions) {
|
||||||
|
final set = actions.map((a) => a.toLowerCase()).toSet();
|
||||||
|
return permissionMatrixActionOrder.where(set.contains).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Module row from `GET /roles/permissions`.
|
||||||
|
class PermissionModuleCatalog {
|
||||||
|
const PermissionModuleCatalog({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final List<String> actions;
|
||||||
|
|
||||||
|
factory PermissionModuleCatalog.fromJson(Map<String, dynamic> json) {
|
||||||
|
final permissions = json['permissions'] as List<dynamic>? ?? const [];
|
||||||
|
final actions = permissions
|
||||||
|
.map((item) => (item as Map<String, dynamic>)['action'] as String?)
|
||||||
|
.whereType<String>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return PermissionModuleCatalog(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
code: json['code'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
actions: sortPermissionActions(actions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PermissionMatrixModuleRow {
|
||||||
|
const PermissionMatrixModuleRow({
|
||||||
|
required this.moduleId,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.granted,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String moduleId;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final Map<String, bool> granted;
|
||||||
|
|
||||||
|
PermissionMatrixModuleRow copyWith({
|
||||||
|
Map<String, bool>? granted,
|
||||||
|
}) {
|
||||||
|
return PermissionMatrixModuleRow(
|
||||||
|
moduleId: moduleId,
|
||||||
|
code: code,
|
||||||
|
name: name,
|
||||||
|
granted: granted ?? this.granted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed `GET /roles/{id}/permission-matrix` payload.
|
||||||
|
class RolePermissionMatrix {
|
||||||
|
const RolePermissionMatrix({
|
||||||
|
required this.roleId,
|
||||||
|
required this.roleName,
|
||||||
|
required this.actionColumns,
|
||||||
|
required this.modules,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String roleId;
|
||||||
|
final String roleName;
|
||||||
|
final List<String> actionColumns;
|
||||||
|
final List<PermissionMatrixModuleRow> modules;
|
||||||
|
|
||||||
|
RolePermissionMatrix copyWith({
|
||||||
|
List<PermissionMatrixModuleRow>? modules,
|
||||||
|
}) {
|
||||||
|
return RolePermissionMatrix(
|
||||||
|
roleId: roleId,
|
||||||
|
roleName: roleName,
|
||||||
|
actionColumns: actionColumns,
|
||||||
|
modules: modules ?? this.modules,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory RolePermissionMatrix.fromApiJson(Map<String, dynamic> json) {
|
||||||
|
final role = json['role'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final actionColumns = sortPermissionActions(
|
||||||
|
(json['actions'] as List<dynamic>? ?? const [])
|
||||||
|
.map((e) => e.toString())
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final modules = <PermissionMatrixModuleRow>[];
|
||||||
|
for (final raw in json['modules'] as List<dynamic>? ?? const []) {
|
||||||
|
if (raw is! Map<String, dynamic>) continue;
|
||||||
|
final permissions = raw['permissions'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final granted = <String, bool>{};
|
||||||
|
|
||||||
|
for (final action in actionColumns) {
|
||||||
|
final entry = permissions[action];
|
||||||
|
if (entry is Map<String, dynamic>) {
|
||||||
|
granted[action] = entry['granted'] == true;
|
||||||
|
} else {
|
||||||
|
granted[action] = entry == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.add(
|
||||||
|
PermissionMatrixModuleRow(
|
||||||
|
moduleId: raw['module_id']?.toString() ?? '',
|
||||||
|
code: raw['code'] as String? ?? '',
|
||||||
|
name: raw['name'] as String? ?? '',
|
||||||
|
granted: granted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RolePermissionMatrix(
|
||||||
|
roleId: role['id']?.toString() ?? '',
|
||||||
|
roleName: role['name'] as String? ?? '',
|
||||||
|
actionColumns: actionColumns.isNotEmpty
|
||||||
|
? actionColumns
|
||||||
|
: List<String>.from(permissionMatrixActionOrder),
|
||||||
|
modules: modules,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toSaveJson() {
|
||||||
|
return {
|
||||||
|
'matrix': modules
|
||||||
|
.map(
|
||||||
|
(module) => {
|
||||||
|
'module_id': int.tryParse(module.moduleId) ?? module.moduleId,
|
||||||
|
'actions': {
|
||||||
|
for (final action in actionColumns)
|
||||||
|
action: module.granted[action] ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
({IconData icon, Color color}) permissionModuleAppearance(String code, int index) {
|
||||||
|
const styles = [
|
||||||
|
(icon: Icons.people_outline, color: Color(0xFF2563EB)),
|
||||||
|
(icon: Icons.shield_outlined, color: Color(0xFF16A34A)),
|
||||||
|
(icon: Icons.dataset_outlined, color: Color(0xFFEA580C)),
|
||||||
|
(icon: Icons.storefront_outlined, color: Color(0xFF7C3AED)),
|
||||||
|
(icon: Icons.receipt_long_outlined, color: Color(0xFFCA8A04)),
|
||||||
|
(icon: Icons.inventory_outlined, color: Color(0xFF0891B2)),
|
||||||
|
(icon: Icons.inventory_2_outlined, color: Color(0xFFDC2626)),
|
||||||
|
];
|
||||||
|
|
||||||
|
final normalized = code.toUpperCase();
|
||||||
|
final byCode = switch (normalized) {
|
||||||
|
'USERS' => styles[0],
|
||||||
|
'ROLES' => styles[1],
|
||||||
|
'MASTERS' => styles[2],
|
||||||
|
'VENDOR' => styles[3],
|
||||||
|
'PURCHASE_ORDERS' || 'PO' => styles[4],
|
||||||
|
'GRN' => styles[5],
|
||||||
|
'ASSETS' => styles[6],
|
||||||
|
_ => styles[index % styles.length],
|
||||||
|
};
|
||||||
|
return byCode;
|
||||||
|
}
|
||||||
@ -46,6 +46,7 @@ import '../../modules/settings/presentation/screens/security_settings_screen.dar
|
|||||||
import '../../modules/settings/presentation/screens/settings_screen.dart';
|
import '../../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());
|
||||||
|
|||||||
15
lib/shared/utils/file_download_helper.dart
Normal file
15
lib/shared/utils/file_download_helper.dart
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
||||||
|
/// Saves [bytes] as a file download (web: same-tab download, desktop: save dialog).
|
||||||
|
Future<bool> downloadFile({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String fileName,
|
||||||
|
}) async {
|
||||||
|
final path = await FilePicker.saveFile(
|
||||||
|
fileName: fileName,
|
||||||
|
bytes: Uint8List.fromList(bytes),
|
||||||
|
);
|
||||||
|
return path != null;
|
||||||
|
}
|
||||||
15
lib/shared/utils/navigation_utils.dart
Normal file
15
lib/shared/utils/navigation_utils.dart
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// Navigates to [location] after closing open dialogs, sheets, and overlays.
|
||||||
|
void goAndDismissOverlays(BuildContext context, String location) {
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
context.go(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,12 +31,24 @@ class AppCard extends StatefulWidget {
|
|||||||
|
|
||||||
class _AppCardState extends State<AppCard> {
|
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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
30
lib/shared/widgets/theme_keyed_subtree.dart
Normal file
30
lib/shared/widgets/theme_keyed_subtree.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/theme_provider.dart';
|
||||||
|
|
||||||
|
/// Rebuilds [child] when light/dark mode changes so cached text styles refresh.
|
||||||
|
class ThemeKeyedSubtree extends ConsumerWidget {
|
||||||
|
const ThemeKeyedSubtree({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.pageKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final String? pageKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final themeMode = ref.watch(themeModeProvider);
|
||||||
|
final brightness = Theme.of(context).brightness;
|
||||||
|
final key = pageKey == null
|
||||||
|
? '$themeMode-$brightness'
|
||||||
|
: '$pageKey-$themeMode-$brightness';
|
||||||
|
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(key),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user