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