diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index ff43d9c..b192fe4 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -164,6 +164,7 @@ class ApiEndpoints { static const String settingsGeneral = '/settings/general'; static const String settingsCompany = '/settings/company'; static String settingsCompanyLogo = '/settings/company/logo'; + static const String settingsCompanyFavicon = '/settings/company/favicon'; static const String settingsAsset = '/settings/asset'; static const String settingsNotifications = '/settings/notifications'; static const String settingsEmail = '/settings/email'; diff --git a/lib/core/utils/favicon_store.dart b/lib/core/utils/favicon_store.dart index 996048d..72a7064 100644 --- a/lib/core/utils/favicon_store.dart +++ b/lib/core/utils/favicon_store.dart @@ -14,10 +14,9 @@ class FaviconStore { final value = url?.trim() ?? ''; if (value.isEmpty) { await _prefs.remove(StorageKeys.faviconUrl); - return; + } else { + await _prefs.setString(StorageKeys.faviconUrl, value); } - - await _prefs.setString(StorageKeys.faviconUrl, value); } void apply() { diff --git a/lib/core/utils/favicon_updater.dart b/lib/core/utils/favicon_updater.dart index d4a3ccb..6d19141 100644 --- a/lib/core/utils/favicon_updater.dart +++ b/lib/core/utils/favicon_updater.dart @@ -1,4 +1,4 @@ import 'favicon_updater_stub.dart' - if (dart.library.html) 'favicon_updater_web.dart' as impl; + if (dart.library.js_interop) 'favicon_updater_web.dart' as impl; void updateFavicon(String? url) => impl.updateFavicon(url); diff --git a/lib/core/utils/favicon_updater_web.dart b/lib/core/utils/favicon_updater_web.dart index 2b2b600..6e0cb75 100644 --- a/lib/core/utils/favicon_updater_web.dart +++ b/lib/core/utils/favicon_updater_web.dart @@ -1,36 +1,10 @@ -import 'dart:html' as html; +import 'dart:js_interop'; + +@JS('setAppFavicon') +external void _setAppFavicon(JSString url); void updateFavicon(String? url) { - for (final link in html.document.querySelectorAll('link[rel="icon"]')) { - link.remove(); - } - - final faviconUrl = url?.trim(); - if (faviconUrl == null || faviconUrl.isEmpty) return; - - final link = html.LinkElement() - ..rel = 'icon' - ..href = faviconUrl; - - final type = _resolveMimeType(faviconUrl); - if (type != null) { - link.type = type; - } - - html.document.head?.append(link); -} - -String? _resolveMimeType(String url) { - if (url.startsWith('data:image/')) { - final end = url.indexOf(';'); - if (end > 5) return url.substring(5, end); - } - - final lower = url.toLowerCase(); - if (lower.endsWith('.ico')) return 'image/x-icon'; - if (lower.endsWith('.png')) return 'image/png'; - if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; - if (lower.endsWith('.svg')) return 'image/svg+xml'; - if (lower.endsWith('.webp')) return 'image/webp'; - return null; + final trimmed = url?.trim(); + if (trimmed == null || trimmed.isEmpty) return; + _setAppFavicon(trimmed.toJS); } diff --git a/lib/core/utils/validators.dart b/lib/core/utils/validators.dart index ca1b4d9..c8ad0d8 100644 --- a/lib/core/utils/validators.dart +++ b/lib/core/utils/validators.dart @@ -412,7 +412,7 @@ class Validators { /// Required role name — letters, numbers, and spaces only. static String? roleName(String? value) { - final requiredError = required(value, fieldName: 'Role name'); + final requiredError = required(value, fieldName: 'Role Name'); if (requiredError != null) return requiredError; final trimmed = value!.trim(); diff --git a/lib/modules/assets/presentation/screens/asset_alerts_screen.dart b/lib/modules/assets/presentation/screens/asset_alerts_screen.dart index 12188b6..5263864 100644 --- a/lib/modules/assets/presentation/screens/asset_alerts_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_alerts_screen.dart @@ -64,7 +64,6 @@ class _AssetAlertsScreenState extends ConsumerState ), ], ), - const SizedBox(height: 16), TabBar( controller: _tabController, tabs: const [ @@ -72,7 +71,7 @@ class _AssetAlertsScreenState extends ConsumerState Tab(text: 'Service Alerts'), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Expanded( child: TabBarView( controller: _tabController, @@ -111,11 +110,11 @@ class _ExpiryAlertsTab extends ConsumerWidget { SizedBox( width: 160, child: AppDropdown( - label: 'Days ahead', + label: 'Days Ahead', isDense: true, value: state.expiryDays, options: const [7, 15, 30, 60, 90] - .map((d) => AppDropdownOption(value: d, label: '$d days')) + .map((d) => AppDropdownOption(value: d, label: '$d Days')) .toList(), onChanged: (v) { if (v != null) notifier.setExpiryDays(v); @@ -150,7 +149,7 @@ class _ExpiryAlertsTab extends ConsumerWidget { const SizedBox(height: 16), _AlertsOverview( total: state.expiryAlerts.length, - label: 'Expiry alerts in selected window', + label: 'Expiry Alerts In Selected Window', ), const SizedBox(height: 12), Expanded( @@ -237,7 +236,7 @@ class _ServiceAlertsTab extends ConsumerWidget { const SizedBox(height: 16), _AlertsOverview( total: state.serviceAlerts.length, - label: 'Service reminders', + label: 'Service Reminders', ), const SizedBox(height: 12), Expanded( diff --git a/lib/modules/assets/presentation/screens/asset_list_screen.dart b/lib/modules/assets/presentation/screens/asset_list_screen.dart index e6ab7e6..06345a5 100644 --- a/lib/modules/assets/presentation/screens/asset_list_screen.dart +++ b/lib/modules/assets/presentation/screens/asset_list_screen.dart @@ -72,15 +72,15 @@ class _AssetListScreenState extends ConsumerState { final statuses = _statusOptions( ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [], ); - final categoryFilter = _selectedCategory ?? 'All categories'; - final plantFilter = _selectedPlant ?? 'All plants'; - final statusFilter = _selectedStatus ?? 'All statuses'; + final categoryFilter = _selectedCategory ?? 'All Categories'; + final plantFilter = _selectedPlant ?? 'All Plants'; + final statusFilter = _selectedStatus ?? 'All Statuses'; final statusLabels = { for (final option in (ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [])) option.value: option.label, - 'All statuses': 'All statuses', + 'All Statuses': 'All Statuses', }; final page = state.query.page; final pageSize = state.query.limit; @@ -110,7 +110,6 @@ class _AssetListScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), Expanded( child: RefreshIndicator( onRefresh: () => ref.read(assetsListProvider.notifier).refresh(), @@ -154,19 +153,19 @@ class _AssetListScreenState extends ConsumerState { onCategoryChanged: (value) { setState(() { _selectedCategory = - value == 'All categories' ? null : value; + value == 'All Categories' ? null : value; }); }, onPlantChanged: (value) { setState(() { _selectedPlant = - value == 'All plants' ? null : value; + value == 'All Plants' ? null : value; }); }, onStatusChanged: (value) { setState(() { _selectedStatus = - value == 'All statuses' ? null : value; + value == 'All Statuses' ? null : value; }); }, ); @@ -260,7 +259,7 @@ class _AssetListScreenState extends ConsumerState { names.add(category.name); } final sorted = names.toList()..sort(); - return ['All categories', ...sorted]; + return ['All Categories', ...sorted]; } List _plantOptions(List assets) { @@ -271,7 +270,7 @@ class _AssetListScreenState extends ConsumerState { .toSet() .toList() ..sort(); - return ['All plants', ...names]; + return ['All Plants', ...names]; } List _statusOptions(List apiStatuses) { @@ -279,7 +278,7 @@ class _AssetListScreenState extends ConsumerState { .map((option) => option.value) .where((value) => value.trim().isNotEmpty) .toList(); - return ['All statuses', ...labels]; + return ['All Statuses', ...labels]; } void _viewAsset(AssetModel asset) { @@ -511,6 +510,8 @@ class _AssetDataTable extends StatelessWidget { flex: 1, cellBuilder: (_, asset) => AppStatusChip( status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', + compact: true, + forTable: true, ), ), AppDataColumn( diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index 3bd2941..722f197 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -484,7 +484,7 @@ class _AssetFormPanelState extends ConsumerState { widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null; return SidePanelScaffold( - title: widget.isEditing ? 'Edit asset' : 'Add asset', + title: widget.isEditing ? 'Edit Asset' : 'Add Asset', footer: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -496,7 +496,7 @@ class _AssetFormPanelState extends ConsumerState { ), const SizedBox(width: 12), AppButton( - label: widget.isEditing ? 'Update asset' : 'Save asset', + label: widget.isEditing ? 'Edit Asset' : 'Add Asset', expand: false, icon: Icons.check, isLoading: _isSubmitting, @@ -567,10 +567,10 @@ class _AssetFormPanelState extends ConsumerState { label: 'Asset Name *', validator: (v) { final requiredError = - Validators.required(v, fieldName: 'Asset name'); + Validators.required(v, fieldName: 'Asset Name'); if (requiredError != null) return requiredError; return Validators.minLength(v!.trim(), 2, - fieldName: 'Asset name'); + fieldName: 'Asset Name'); }, ), const SizedBox(height: 12), @@ -607,7 +607,7 @@ class _AssetFormPanelState extends ConsumerState { return Validators.minLength( v.trim(), 2, - fieldName: 'Serial number', + fieldName: 'Serial Number', ); }, ), @@ -620,7 +620,7 @@ class _AssetFormPanelState extends ConsumerState { return Validators.minLength( v.trim(), 2, - fieldName: 'Part number', + fieldName: 'Part Number', ); }, ), @@ -636,7 +636,7 @@ class _AssetFormPanelState extends ConsumerState { return Validators.minLength( v.trim(), 2, - fieldName: 'Brand / model', + fieldName: 'Brand / Model', ); }, ), @@ -697,7 +697,7 @@ class _AssetFormPanelState extends ConsumerState { return Validators.minLength( v.trim(), 2, - fieldName: 'Location detail', + fieldName: 'Location Detail', ); }, ), @@ -792,7 +792,7 @@ class _AssetFormPanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalPositiveDouble( v, - fieldName: 'Purchase cost', + fieldName: 'Purchase Cost', ), ), right: AppTextField( @@ -802,7 +802,7 @@ class _AssetFormPanelState extends ConsumerState { keyboardType: TextInputType.number, validator: (v) => Validators.optionalPositiveInt( v, - fieldName: 'Useful life', + fieldName: 'Useful Life', ), ), ), @@ -829,7 +829,7 @@ class _AssetFormPanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalPercentage( v, - fieldName: 'Depreciation rate', + fieldName: 'Depreciation Rate', ), ), ), @@ -842,7 +842,7 @@ class _AssetFormPanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalNonNegativeDouble( v, - fieldName: 'Salvage value', + fieldName: 'Salvage Value', ), ), right: const SizedBox.shrink(), @@ -913,7 +913,7 @@ class _AssetFormPanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalNonNegativeDouble( v, - fieldName: 'Disposal value', + fieldName: 'Disposal Value', ), ), ), @@ -927,13 +927,13 @@ class _AssetFormPanelState extends ConsumerState { final isDisposed = _status == 'DISPOSED' || _status == 'SCRAPPED'; if (isDisposed) { - return Validators.required(v, fieldName: 'Disposal reason'); + return Validators.required(v, fieldName: 'Disposal Reason'); } if (v == null || v.trim().isEmpty) return null; return Validators.minLength( v.trim(), 3, - fieldName: 'Disposal reason', + fieldName: 'Disposal Reason', ); }, ), @@ -951,7 +951,7 @@ class _AssetFormPanelState extends ConsumerState { return Validators.minLength( v.trim(), 2, - fieldName: 'QR code value', + fieldName: 'QR Code Value', ); }, ), diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 4cd1172..bd97afd 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -317,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState { keyboardType: TextInputType.number, validator: (v) => Validators.optionalPositiveInt( v, - fieldName: 'Visits per year', + fieldName: 'Visits Per Year', ), ), const SizedBox(height: 12), @@ -386,7 +386,7 @@ class _AddAmcPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: true, - saveLabel: 'Update contract', + saveLabel: 'Edit AMC Contract', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), @@ -402,7 +402,7 @@ class _AddAmcPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Update contract', + saveLabel: 'Edit AMC Contract', onSave: _save, ), child: _buildForm(), @@ -416,7 +416,7 @@ class _AddAmcPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Save contract', + saveLabel: 'Add AMC Contract', onSave: _save, ), child: _buildForm(), @@ -670,7 +670,7 @@ class _LogServiceVisitPanelState extends ConsumerState { keyboardType: TextInputType.number, validator: (v) => Validators.optionalPositiveInt( v, - fieldName: 'Visit number', + fieldName: 'Visit Number', ), ), ), @@ -783,7 +783,7 @@ class _LogServiceVisitPanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalPositiveDouble( v, - fieldName: 'Downtime hours', + fieldName: 'Downtime Hours', ), ), ), @@ -794,7 +794,7 @@ class _LogServiceVisitPanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalPositiveDouble( v, - fieldName: 'Service cost', + fieldName: 'Service Cost', ), ), const SizedBox(height: 12), @@ -830,7 +830,7 @@ class _LogServiceVisitPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: true, - saveLabel: 'Update visit', + saveLabel: 'Edit Service Visit', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), @@ -846,7 +846,7 @@ class _LogServiceVisitPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Update visit', + saveLabel: 'Edit Service Visit', onSave: _save, ), child: _buildForm(widget.amcContracts), @@ -860,7 +860,7 @@ class _LogServiceVisitPanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Save visit', + saveLabel: 'Log Service Visit', onSave: _save, ), child: _buildForm(widget.amcContracts), @@ -1046,12 +1046,12 @@ class _AddInsurancePanelState extends ConsumerState { left: AppTextField( controller: _policyNoController, label: 'Policy No *', - validator: (v) => Validators.required(v, fieldName: 'Policy no'), + validator: (v) => Validators.required(v, fieldName: 'Policy No'), ), right: AppTextField( controller: _insurerNameController, label: 'Insurer Name *', - validator: (v) => Validators.required(v, fieldName: 'Insurer name'), + validator: (v) => Validators.required(v, fieldName: 'Insurer Name'), ), ), const SizedBox(height: 12), @@ -1096,7 +1096,7 @@ class _AddInsurancePanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalNonNegativeDouble( v, - fieldName: 'Sum insured', + fieldName: 'Sum Insured', ), ), ), @@ -1107,7 +1107,7 @@ class _AddInsurancePanelState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) => Validators.optionalNonNegativeDouble( v, - fieldName: 'Annual premium', + fieldName: 'Annual Premium', ), ), const SizedBox(height: 12), @@ -1195,7 +1195,7 @@ class _AddInsurancePanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: true, - saveLabel: 'Update policy', + saveLabel: 'Edit Insurance Policy', onSave: () {}, ), child: const Center(child: CircularProgressIndicator()), @@ -1211,7 +1211,7 @@ class _AddInsurancePanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Update policy', + saveLabel: 'Edit Insurance Policy', onSave: _save, ), child: _buildForm(), @@ -1225,7 +1225,7 @@ class _AddInsurancePanelState extends ConsumerState { footer: _panelFooter( context, isSubmitting: _isSubmitting, - saveLabel: 'Save policy', + saveLabel: 'Add Insurance Policy', onSave: _save, ), child: _buildForm(), diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index 10d5197..7e69475 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -148,7 +148,6 @@ class _AuditLogsScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), Expanded( child: AppTableShell( toolbar: LayoutBuilder( @@ -296,7 +295,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search table...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All tables'), + const AppDropdownOption(value: null, label: 'All Tables'), ...filters.tableNames.map( (name) => AppDropdownOption(value: name, label: name), ), @@ -310,7 +309,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search action...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All actions'), + const AppDropdownOption(value: null, label: 'All Actions'), ...filters.actions.map( (action) => AppDropdownOption(value: action, label: action), ), @@ -319,12 +318,12 @@ class _FiltersBar extends StatelessWidget { ); final performerDropdown = AppSearchableDropdown( - label: 'Performed by', + label: 'Performed By', value: query.performedBy, searchHint: 'Search user...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All users'), + const AppDropdownOption(value: null, label: 'All Users'), ...filters.performers.map( (user) => AppDropdownOption( value: int.tryParse(user.id), @@ -336,7 +335,7 @@ class _FiltersBar extends StatelessWidget { ); final dateField = AppFilterDateField( - label: 'Date range', + label: 'Date Range', value: _dateValue, placeholder: 'Select range', icon: Icons.date_range_outlined, @@ -427,7 +426,11 @@ class _AuditDataTable extends StatelessWidget { label: 'Action', flex: 1, cellBuilder: (_, row) => AppTableCell.child( - AppStatusChip(status: row.action, compact: true), + AppStatusChip( + status: row.action, + compact: true, + forTable: true, + ), ), ), AppDataColumn( @@ -441,7 +444,7 @@ class _AuditDataTable extends StatelessWidget { cellBuilder: (_, row) => AppTableCell.text(row.recordId), ), AppDataColumn( - label: 'Performed by', + label: 'Performed By', flex: 2, cellBuilder: (_, row) => AppTableCell.text(row.performerLabel), ), diff --git a/lib/modules/auth/presentation/screens/change_password_screen.dart b/lib/modules/auth/presentation/screens/change_password_screen.dart index 45b38bd..70ecc9d 100644 --- a/lib/modules/auth/presentation/screens/change_password_screen.dart +++ b/lib/modules/auth/presentation/screens/change_password_screen.dart @@ -42,7 +42,7 @@ class _ChangePasswordScreenState extends State { controller: _currentController, label: 'Current Password', obscureText: true, - validator: (v) => Validators.required(v, fieldName: 'Current password'), + validator: (v) => Validators.required(v, fieldName: 'Current Password'), ), const SizedBox(height: 16), AppTextField( diff --git a/lib/modules/auth/presentation/screens/reset_password_screen.dart b/lib/modules/auth/presentation/screens/reset_password_screen.dart index 86297b3..c5f952c 100644 --- a/lib/modules/auth/presentation/screens/reset_password_screen.dart +++ b/lib/modules/auth/presentation/screens/reset_password_screen.dart @@ -51,7 +51,7 @@ class _ResetPasswordScreenState extends State { obscureText: true, validator: (v) { if (v != _passwordController.text) return 'Passwords do not match'; - return Validators.required(v, fieldName: 'Confirm password'); + return Validators.required(v, fieldName: 'Confirm Password'); }, ), const SizedBox(height: 24), diff --git a/lib/modules/branch/presentation/screens/branch_form_screen.dart b/lib/modules/branch/presentation/screens/branch_form_screen.dart index 0ab86a4..3c6e0e4 100644 --- a/lib/modules/branch/presentation/screens/branch_form_screen.dart +++ b/lib/modules/branch/presentation/screens/branch_form_screen.dart @@ -47,13 +47,13 @@ class _BranchFormScreenState extends State { AppTextField( controller: _nameController, label: 'Branch Name', - validator: (v) => Validators.required(v, fieldName: 'Branch name'), + validator: (v) => Validators.required(v, fieldName: 'Branch Name'), ), const SizedBox(height: 16), AppTextField( controller: _codeController, label: 'Branch Code', - validator: (v) => Validators.required(v, fieldName: 'Branch code'), + validator: (v) => Validators.required(v, fieldName: 'Branch Code'), ), const SizedBox(height: 16), AppTextField(controller: _locationController, label: 'Location'), diff --git a/lib/modules/company/presentation/screens/company_form_screen.dart b/lib/modules/company/presentation/screens/company_form_screen.dart index b522f88..6687291 100644 --- a/lib/modules/company/presentation/screens/company_form_screen.dart +++ b/lib/modules/company/presentation/screens/company_form_screen.dart @@ -51,13 +51,13 @@ class _CompanyFormScreenState extends State { AppTextField( controller: _nameController, label: 'Company Name', - validator: (v) => Validators.required(v, fieldName: 'Company name'), + validator: (v) => Validators.required(v, fieldName: 'Company Name'), ), const SizedBox(height: 16), AppTextField( controller: _codeController, label: 'Company Code', - validator: (v) => Validators.required(v, fieldName: 'Company code'), + validator: (v) => Validators.required(v, fieldName: 'Company Code'), ), const SizedBox(height: 16), AppTextField( diff --git a/lib/modules/company/presentation/screens/company_list_screen.dart b/lib/modules/company/presentation/screens/company_list_screen.dart index 5c0e78c..1318036 100644 --- a/lib/modules/company/presentation/screens/company_list_screen.dart +++ b/lib/modules/company/presentation/screens/company_list_screen.dart @@ -48,7 +48,6 @@ class _CompanyListScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), Expanded( child: profile.companyName.isEmpty ? const AppEmptyState( diff --git a/lib/modules/grn/presentation/screens/grn_detail_screen.dart b/lib/modules/grn/presentation/screens/grn_detail_screen.dart index d0a1a1e..88087ff 100644 --- a/lib/modules/grn/presentation/screens/grn_detail_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_detail_screen.dart @@ -30,6 +30,7 @@ class GrnDetailScreen extends ConsumerStatefulWidget { class _GrnDetailScreenState extends ConsumerState { bool _isWorking = false; + bool _isDownloadingPdf = false; @override Widget build(BuildContext context) { @@ -60,6 +61,7 @@ class _GrnDetailScreenState extends ConsumerState { _DetailHeader( grn: grn, isWorking: _isWorking, + isDownloadingPdf: _isDownloadingPdf, canEdit: canEdit, canExport: canExport, onBack: () => context.go(RouteConstants.grn), @@ -141,7 +143,7 @@ class _GrnDetailScreenState extends ConsumerState { ), const SizedBox(height: 16), AppTextField( - label: 'Cancellation reason *', + label: 'Cancellation Reason *', controller: reasonController, maxLines: 3, ), @@ -174,7 +176,8 @@ class _GrnDetailScreenState extends ConsumerState { } Future _downloadPdf(GrnModel grn) async { - await _runWorkflow(() async { + setState(() => _isDownloadingPdf = true); + try { final bytes = await ref .read(grnDetailProvider(widget.grnId).notifier) .downloadPdf(); @@ -182,7 +185,18 @@ class _GrnDetailScreenState extends ConsumerState { bytes: bytes, fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf', ); - }, 'PDF downloaded'); + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('PDF downloaded'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } finally { + if (mounted) setState(() => _isDownloadingPdf = false); + } } } @@ -204,6 +218,7 @@ class _DetailHeader extends StatelessWidget { const _DetailHeader({ required this.grn, required this.isWorking, + required this.isDownloadingPdf, required this.canEdit, required this.canExport, required this.onBack, @@ -214,6 +229,7 @@ class _DetailHeader extends StatelessWidget { final GrnModel grn; final bool isWorking; + final bool isDownloadingPdf; final bool canEdit; final bool canExport; final VoidCallback onBack; @@ -241,7 +257,8 @@ class _DetailHeader extends StatelessWidget { _HeaderActionButton( label: 'PDF', icon: Icons.description_outlined, - onPressed: isWorking ? null : onPdf, + isLoading: isDownloadingPdf, + onPressed: (isWorking || isDownloadingPdf) ? null : onPdf, ), if (canEdit && grn.canEdit) _HeaderActionButton( @@ -336,12 +353,14 @@ class _HeaderActionButton extends StatelessWidget { required this.label, required this.icon, required this.onPressed, + this.isLoading = false, this.destructive = false, }); final String label; final IconData icon; final VoidCallback? onPressed; + final bool isLoading; final bool destructive; static const double _height = 40; @@ -368,7 +387,17 @@ class _HeaderActionButton extends StatelessWidget { final child = Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 18), + if (isLoading) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: destructive ? error : null, + ), + ) + else + Icon(icon, size: 18), const SizedBox(width: 8), Text(label), ], @@ -495,11 +524,11 @@ class _ReceiptDetailsCard extends StatelessWidget { final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; final items = [ _DetailField( - label: 'GRN date', + label: 'GRN Date', value: DateFormatter.displayDate(grn.grnDate), ), _DetailField( - label: 'PO number', + label: 'PO Number', value: _displayOrDash(grn.poNumber), ), _DetailField( @@ -511,37 +540,37 @@ class _ReceiptDetailsCard extends StatelessWidget { value: _displayOrDash(grn.warehouseName), ), _DetailField( - label: 'Vendor invoice no', + label: 'Vendor Invoice No', value: _displayOrDash(grn.vendorInvoiceNo), ), _DetailField( - label: 'Vendor invoice date', + label: 'Vendor Invoice Date', value: DateFormatter.displayDate(grn.vendorInvoiceDate), ), _DetailField( - label: 'Vendor invoice amount', + label: 'Vendor Invoice Amount', value: grn.vendorInvoiceAmount != null ? CurrencyFormatter.format(grn.vendorInvoiceAmount!) : '—', ), _DetailField( - label: 'Vehicle no', + label: 'Vehicle No', value: _displayOrDash(grn.vehicleNo), ), _DetailField( - label: 'LR no', + label: 'LR No', value: _displayOrDash(grn.lrNo), ), _DetailField( - label: 'LR date', + label: 'LR Date', value: DateFormatter.displayDate(grn.lrDate), ), _DetailField( - label: 'Received by', + label: 'Received By', value: _userLabel(lookups?.users, grn.receivedBy), ), _DetailField( - label: 'Quality checked by', + label: 'Quality Checked By', value: _userLabel(lookups?.users, grn.qualityCheckedBy), ), ]; diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index a8e61c7..b455e8d 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -6,6 +7,7 @@ import '../../../../core/constants/route_constants.dart'; import '../../../../core/errors/failure.dart'; import '../../../../core/network/api_handler.dart'; import '../../../../core/utils/formatters.dart'; +import '../../../../core/utils/validators.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; @@ -405,7 +407,7 @@ class _GrnFormScreenState extends ConsumerState { FormRowFour( children: [ _DateField( - label: 'GRN date *', + label: 'GRN Date *', value: _grnDate, enabled: !widget.isEditing, onTap: widget.isEditing @@ -418,7 +420,7 @@ class _GrnFormScreenState extends ConsumerState { ), if (!widget.isEditing) AppSearchableDropdown( - label: 'Purchase order *', + label: 'Purchase Order *', value: _selectedPoId, hint: 'Select PO', searchHint: 'Search PO...', @@ -447,12 +449,12 @@ class _GrnFormScreenState extends ConsumerState { } }, validator: (v) => v == null - ? 'Purchase order is required' + ? 'Purchase Order Is Required' : null, ) else _ReadOnlyField( - label: 'Purchase order', + label: 'Purchase Order', value: existing?.poNumber ?? '—', ), MasterQuickAddDropdown( @@ -475,7 +477,7 @@ class _GrnFormScreenState extends ConsumerState { enabled: !widget.isEditing, ), AppTextField( - label: 'Vendor invoice no', + label: 'Vendor Invoice No', controller: _vendorInvoiceNoController, ), ], @@ -483,7 +485,7 @@ class _GrnFormScreenState extends ConsumerState { FormRowFour( children: [ _DateField( - label: 'Vendor invoice date', + label: 'Vendor Invoice Date', value: _vendorInvoiceDate, onTap: () => _pickDate( current: _vendorInvoiceDate, @@ -492,18 +494,27 @@ class _GrnFormScreenState extends ConsumerState { ), ), AppTextField( - label: 'Vendor invoice amount', + label: 'Vendor Invoice Amount', controller: _vendorInvoiceAmountController, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d{0,2}'), + ), + ], + validator: (v) => Validators.optionalNonNegativeDouble( + v, + fieldName: 'Vendor Invoice Amount', + ), ), AppTextField( - label: 'Vehicle no', + label: 'Vehicle No', controller: _vehicleNoController, ), AppTextField( - label: 'LR no', + label: 'LR No', controller: _lrNoController, ), ], @@ -511,7 +522,7 @@ class _GrnFormScreenState extends ConsumerState { FormRowFour( children: [ _DateField( - label: 'LR date', + label: 'LR Date', value: _lrDate, onTap: () => _pickDate( current: _lrDate, @@ -519,7 +530,7 @@ class _GrnFormScreenState extends ConsumerState { ), ), AppSearchableDropdown( - label: 'Received by', + label: 'Received By', value: _dropdownValue( _normalizeUserId(_receivedById), userIds, @@ -531,7 +542,7 @@ class _GrnFormScreenState extends ConsumerState { setState(() => _receivedById = v), ), AppSearchableDropdown( - label: 'Quality checked by', + label: 'Quality Checked By', value: _dropdownValue( _normalizeUserId(_qualityCheckedById), userIds, @@ -616,7 +627,7 @@ class _GrnFormScreenState extends ConsumerState { final theme = Theme.of(context); final title = widget.isEditing ? 'Edit ${existing?.grnNumber ?? 'GRN'}' - : 'Create goods received note'; + : 'Create Goods Received Note'; final subtitle = widget.isEditing ? null : 'Select an approved purchase order, enter receipt details, then confirm quantities.'; diff --git a/lib/modules/grn/presentation/screens/grn_list_screen.dart b/lib/modules/grn/presentation/screens/grn_list_screen.dart index a61fc79..a179546 100644 --- a/lib/modules/grn/presentation/screens/grn_list_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_list_screen.dart @@ -71,7 +71,6 @@ class _GrnListScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), Expanded( child: AppTableShell( toolbar: LayoutBuilder( @@ -190,7 +189,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search status...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All statuses'), + const AppDropdownOption(value: null, label: 'All Statuses'), ...grnStatusOptions.map( (e) => AppDropdownOption(value: e.$1, label: e.$2), ), @@ -257,15 +256,14 @@ class _GrnDataTable extends StatelessWidget { flex: 2, cellBuilder: (_, grn) => Text(grn.vendorName ?? '—'), ), - AppDataColumn( - label: 'Warehouse', - flex: 2, - cellBuilder: (_, grn) => Text(grn.warehouseName ?? '—'), - ), AppDataColumn( label: 'Status', flex: 1, - cellBuilder: (_, grn) => GrnStatusChip(status: grn.status, compact: true), + cellBuilder: (_, grn) => GrnStatusChip( + status: grn.status, + compact: true, + forTable: true, + ), ), AppDataColumn( label: 'Actions', diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index b5291e7..f0e4c94 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -223,9 +223,14 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')), ]; + late final TextEditingController _currentQtyController; + @override void initState() { super.initState(); + _currentQtyController = TextEditingController( + text: _formatQty(widget.item.currentQty), + ); widget.item.acceptedQtyController.addListener(_onFieldChanged); widget.item.rejectedQtyController.addListener(_onFieldChanged); } @@ -234,10 +239,18 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { void dispose() { widget.item.acceptedQtyController.removeListener(_onFieldChanged); widget.item.rejectedQtyController.removeListener(_onFieldChanged); + _currentQtyController.dispose(); super.dispose(); } void _onFieldChanged() { + final next = _formatQty(widget.item.currentQty); + if (_currentQtyController.text != next) { + _currentQtyController.value = TextEditingValue( + text: next, + selection: TextSelection.collapsed(offset: next.length), + ); + } widget.onChanged(); setState(() {}); } @@ -319,7 +332,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { const SizedBox(height: 8), FormRow( columnCount: 12, - spans: const [1, 1, 1, 2, 3, 4], + spans: const [2, 2, 2, 2, 2, 2], spacing: 8, stackBelowWidth: 1100, children: [ @@ -377,22 +390,21 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { return null; }, ), - _GrnLineReadOnlyField( + AppTextField( key: ValueKey('$lineKey-current'), + controller: _currentQtyController, label: 'Current', - value: _formatQty(item.currentQty), - backgroundColor: currentBg, + isDense: true, + readOnly: true, + fillColor: currentBg, ), AppTextField( key: ValueKey('$lineKey-rate'), controller: item.rateController, label: 'Rate', hint: '0.00', - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: _qtyFormatters, isDense: true, - onChanged: (_) => widget.onChanged(), + readOnly: true, ), AppTextField( key: ValueKey('$lineKey-batch'), @@ -524,43 +536,6 @@ class _LineItemTitle extends StatelessWidget { } } -class _GrnLineReadOnlyField extends StatelessWidget { - const _GrnLineReadOnlyField({ - super.key, - required this.label, - required this.value, - this.backgroundColor, - }); - - final String label; - final String value; - final Color? backgroundColor; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.only(top: 8), - child: InputDecorator( - decoration: InputDecoration( - labelText: label, - floatingLabelBehavior: FloatingLabelBehavior.always, - isDense: true, - filled: backgroundColor != null, - fillColor: backgroundColor, - enabled: false, - ), - child: Text( - value, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } -} - class _GrnLineDateField extends StatelessWidget { const _GrnLineDateField({ super.key, diff --git a/lib/modules/grn/presentation/widgets/grn_status_chip.dart b/lib/modules/grn/presentation/widgets/grn_status_chip.dart index 84b56f5..7fd9e32 100644 --- a/lib/modules/grn/presentation/widgets/grn_status_chip.dart +++ b/lib/modules/grn/presentation/widgets/grn_status_chip.dart @@ -1,20 +1,32 @@ import 'package:flutter/material.dart'; import '../../../../shared/models/grn_model.dart'; +import '../../../../shared/widgets/app_status_chip.dart'; class GrnStatusChip extends StatelessWidget { const GrnStatusChip({ super.key, required this.status, this.compact = false, + this.forTable = false, }); final String status; final bool compact; + final bool forTable; @override Widget build(BuildContext context) { final (color, label) = _resolveStatus(status); + if (forTable) { + return TableStatusBadge( + label: label, + color: color, + compact: compact, + fixedWidth: kTableStatusChipWidth, + ); + } + return Chip( label: Text( label, diff --git a/lib/modules/master_data/presentation/screens/master_list_screen.dart b/lib/modules/master_data/presentation/screens/master_list_screen.dart index ed2e6a3..1fc3283 100644 --- a/lib/modules/master_data/presentation/screens/master_list_screen.dart +++ b/lib/modules/master_data/presentation/screens/master_list_screen.dart @@ -14,6 +14,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_search_export_bar.dart'; import '../../../../shared/widgets/app_status_chip.dart'; +import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/app_side_panel.dart'; @@ -185,7 +186,6 @@ class _MasterListScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), Expanded( child: AppTableShell( toolbar: LayoutBuilder( @@ -286,6 +286,7 @@ class _MasterListTable extends StatelessWidget { cellBuilder: (_, row) => AppStatusChip( status: masterStatusValue(row), compact: true, + forTable: true, ), ), AppDataColumn( @@ -294,24 +295,22 @@ class _MasterListTable extends StatelessWidget { alignment: Alignment.centerRight, cellBuilder: (_, row) { final id = row['id']?.toString(); - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, + return AppTableActions( children: [ if (canEdit) - IconButton( + AppTableActionIcon( tooltip: 'Edit', - icon: const Icon(Icons.edit_outlined), - onPressed: id == null ? null : () => onEdit(id), + icon: Icons.edit_outlined, + enabled: id != null, + onPressed: () => onEdit(id!), ), if (canDelete) - IconButton( + AppTableActionIcon( tooltip: 'Delete', - icon: Icon( - Icons.delete_outline, - color: theme.colorScheme.error, - ), - onPressed: isDeleting ? null : () => onDelete(row), + icon: Icons.delete_outline, + color: theme.colorScheme.error, + enabled: !isDeleting, + onPressed: () => onDelete(row), ), ], ); diff --git a/lib/modules/master_data/presentation/widgets/master_form_panel.dart b/lib/modules/master_data/presentation/widgets/master_form_panel.dart index 0e8ae0c..07d1b51 100644 --- a/lib/modules/master_data/presentation/widgets/master_form_panel.dart +++ b/lib/modules/master_data/presentation/widgets/master_form_panel.dart @@ -334,9 +334,28 @@ class _MasterFormPanelState extends ConsumerState { final widgets = []; - for (var i = 0; i < regularFields.length; i += 2) { + var i = 0; + while (i < regularFields.length) { final left = regularFields[i]; - if (i + 1 < regularFields.length) { + + // Multiline fields (e.g. HSN Description) always take a full row. + if (left.multiline) { + widgets.add( + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + child: _buildField(context, field: left, formState: formState), + ), + ), + ); + i += 1; + continue; + } + + final hasRight = + i + 1 < regularFields.length && !regularFields[i + 1].multiline; + if (hasRight) { final right = regularFields[i + 1]; widgets.add( SidePanelFormRow( @@ -344,6 +363,7 @@ class _MasterFormPanelState extends ConsumerState { right: _buildField(context, field: right, formState: formState), ), ); + i += 2; } else { widgets.add( Padding( @@ -354,6 +374,7 @@ class _MasterFormPanelState extends ConsumerState { ), ), ); + i += 1; } } @@ -378,7 +399,7 @@ class _MasterFormPanelState extends ConsumerState { final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; return SidePanelScaffold( - title: widget.isEditing ? 'Edit ${def.title.toLowerCase()}' : 'Add ${def.title.toLowerCase()}', + title: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}', footer: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -390,7 +411,7 @@ class _MasterFormPanelState extends ConsumerState { ), const SizedBox(width: 12), AppButton( - label: widget.isEditing ? 'Update ${def.title.toLowerCase()}' : 'Save ${def.title.toLowerCase()}', + label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}', expand: false, icon: Icons.check, isLoading: isSubmitting, diff --git a/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart b/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart index f8f858f..f816550 100644 --- a/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart +++ b/lib/modules/master_data/presentation/widgets/master_inline_create_form.dart @@ -392,7 +392,7 @@ class _MasterInlineCreateFormState const SizedBox(width: 8), Expanded( child: Text( - 'Add ${def.title.toLowerCase()}', + 'Add ${def.title}', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w700, ), @@ -464,7 +464,7 @@ class _MasterInlineCreateFormState ), const SizedBox(width: 8), AppButton( - label: 'Save', + label: 'Add ${def.title}', expand: false, icon: Icons.check, isLoading: isSubmitting, diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart index 39a2a32..54d67c8 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_detail_screen.dart @@ -34,6 +34,7 @@ class PurchaseOrderDetailScreen extends ConsumerStatefulWidget { class _PurchaseOrderDetailScreenState extends ConsumerState { bool _isWorking = false; + bool _isDownloadingPdf = false; @override Widget build(BuildContext context) { @@ -70,6 +71,7 @@ class _PurchaseOrderDetailScreenState _DetailHeader( order: order, isWorking: _isWorking, + isDownloadingPdf: _isDownloadingPdf, canEdit: canEdit, canDelete: canDelete, canApprove: canApprove, @@ -288,7 +290,7 @@ class _PurchaseOrderDetailScreenState } Future _downloadPdf(PurchaseOrderModel order) async { - setState(() => _isWorking = true); + setState(() => _isDownloadingPdf = true); try { final bytes = await ref .read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier) @@ -304,7 +306,7 @@ class _PurchaseOrderDetailScreenState .showSnackBar(SnackBar(content: Text(e.toString()))); } } finally { - if (mounted) setState(() => _isWorking = false); + if (mounted) setState(() => _isDownloadingPdf = false); } } } @@ -376,6 +378,7 @@ class _DetailHeader extends StatelessWidget { const _DetailHeader({ required this.order, required this.isWorking, + required this.isDownloadingPdf, required this.canEdit, required this.canDelete, required this.canApprove, @@ -393,6 +396,7 @@ class _DetailHeader extends StatelessWidget { final PurchaseOrderModel order; final bool isWorking; + final bool isDownloadingPdf; final bool canEdit; final bool canDelete; final bool canApprove; @@ -426,7 +430,8 @@ class _DetailHeader extends StatelessWidget { _HeaderActionButton( label: 'PDF', icon: Icons.description_outlined, - onPressed: isWorking ? null : onPdf, + isLoading: isDownloadingPdf, + onPressed: (isWorking || isDownloadingPdf) ? null : onPdf, ), if (canEdit && order.canEdit) _HeaderActionButton( @@ -557,6 +562,7 @@ class _HeaderActionButton extends StatelessWidget { required this.label, required this.icon, required this.onPressed, + this.isLoading = false, this.filled = false, this.destructive = false, }); @@ -564,6 +570,7 @@ class _HeaderActionButton extends StatelessWidget { final String label; final IconData icon; final VoidCallback? onPressed; + final bool isLoading; final bool filled; final bool destructive; @@ -592,7 +599,19 @@ class _HeaderActionButton extends StatelessWidget { final child = Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 18), + if (isLoading) + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: filled + ? theme.colorScheme.onPrimary + : (destructive ? error : null), + ), + ) + else + Icon(icon, size: 18), const SizedBox(width: 8), Text(label), ], @@ -699,11 +718,11 @@ class _OrderDetailsCard extends StatelessWidget { final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; final items = [ _DetailField( - label: 'PO date', + label: 'PO Date', value: DateFormatter.displayDate(order.poDate), ), _DetailField( - label: 'Expected delivery', + label: 'Expected Delivery', value: DateFormatter.displayDate(order.expectedDeliveryDate), ), _DetailField( @@ -711,7 +730,7 @@ class _OrderDetailsCard extends StatelessWidget { value: _displayOrDash(order.vendorName), ), _DetailField( - label: 'PO type', + label: 'PO Type', value: poTypeLabel(order.poType), ), _DetailField( @@ -723,8 +742,8 @@ class _OrderDetailsCard extends StatelessWidget { value: _displayOrDash(order.warehouseName), ), _DetailField(label: 'Brand', value: brand), - _DetailField(label: 'Payment term', value: paymentTerm), - _DetailField(label: 'Delivery term', value: deliveryTerm), + _DetailField(label: 'Payment Term', value: paymentTerm), + _DetailField(label: 'Delivery Term', value: deliveryTerm), ]; return Wrap( @@ -1076,7 +1095,7 @@ class _AmountSummaryCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _SummaryRow( - label: 'Taxable amount', + label: 'Taxable Amount', value: CurrencyFormatter.format(order.taxableAmount), ), const SizedBox(height: 12), @@ -1086,12 +1105,12 @@ class _AmountSummaryCard extends StatelessWidget { ), const SizedBox(height: 12), _SummaryRow( - label: 'Freight charges', + label: 'Freight Charges', value: CurrencyFormatter.format(order.freightCharges ?? 0), ), const SizedBox(height: 12), _SummaryRow( - label: 'Other charges', + label: 'Other Charges', value: CurrencyFormatter.format(order.otherCharges ?? 0), ), const SizedBox(height: 12), diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index 0a7e7f0..4e834b1 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -387,7 +387,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState _pickDate( current: _poDate, @@ -395,7 +395,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState( - label: 'PO type *', + label: 'PO Type *', value: _poType, hint: 'Select PO type', searchHint: 'Search type...', @@ -466,7 +466,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState( masterId: 'payment_terms', - label: 'Payment term', + label: 'Payment Term', value: _paymentTermId, hint: 'Select payment term', searchHint: 'Search payment term...', @@ -480,7 +480,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState( masterId: 'delivery_terms', - label: 'Delivery term', + label: 'Delivery Term', value: _deliveryTermId, hint: 'Select delivery term', searchHint: 'Search delivery term...', @@ -498,7 +498,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState _pickDate( current: _expectedDeliveryDate, @@ -536,7 +536,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState 0 ? AppColors.error diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 94a92c1..9c90e52 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -98,7 +98,6 @@ class _PurchaseOrderListScreenState extends ConsumerState a.label.compareTo(b.label)); return [ - const AppDropdownOption(value: null, label: 'All statuses'), + const AppDropdownOption(value: null, label: 'All Statuses'), ...options, ]; } @@ -277,7 +276,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search type...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All types'), + const AppDropdownOption(value: null, label: 'All Types'), ...poTypeOptions.map( (e) => AppDropdownOption(value: e.$1, label: e.$2), ), @@ -345,16 +344,6 @@ class _PoDataTable extends StatelessWidget { flex: 2, cellBuilder: (_, order) => Text(order.vendorName ?? '—'), ), - AppDataColumn( - label: 'Plant', - flex: 2, - cellBuilder: (_, order) => Text(order.plantName ?? '—'), - ), - AppDataColumn( - label: 'Type', - flex: 2, - cellBuilder: (_, order) => Text(poTypeLabel(order.poType)), - ), AppDataColumn( label: 'Total', flex: 1, @@ -364,7 +353,11 @@ class _PoDataTable extends StatelessWidget { AppDataColumn( label: 'Status', flex: 1, - cellBuilder: (_, order) => PoStatusChip(status: order.status), + cellBuilder: (_, order) => PoStatusChip( + status: order.status, + compact: true, + forTable: true, + ), ), AppDataColumn( label: 'Actions', diff --git a/lib/modules/purchase_orders/presentation/widgets/po_status_chip.dart b/lib/modules/purchase_orders/presentation/widgets/po_status_chip.dart index b30f440..5a9d322 100644 --- a/lib/modules/purchase_orders/presentation/widgets/po_status_chip.dart +++ b/lib/modules/purchase_orders/presentation/widgets/po_status_chip.dart @@ -1,20 +1,31 @@ import 'package:flutter/material.dart'; import '../../../../shared/models/purchase_order_model.dart'; +import '../../../../shared/widgets/app_status_chip.dart'; class PoStatusChip extends StatelessWidget { const PoStatusChip({ super.key, required this.status, this.compact = false, + this.forTable = false, }); final String status; final bool compact; + final bool forTable; @override Widget build(BuildContext context) { final (color, label) = _resolveStatus(status); + if (forTable) { + return TableStatusBadge( + label: label, + color: color, + compact: compact, + fixedWidth: kTableStatusChipWidth, + ); + } return _PoBadge(label: label, color: color, compact: compact); } diff --git a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart index 18fb392..6d11d44 100644 --- a/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart +++ b/lib/modules/purchase_orders/presentation/widgets/purchase_order_line_items_editor.dart @@ -263,7 +263,7 @@ class _PurchaseOrderLineItemsEditorState widget.onChanged?.call(); }, icon: const Icon(Icons.add, size: 18), - label: const Text('Add line'), + label: const Text('Add Line'), style: TextButton.styleFrom( foregroundColor: theme.colorScheme.primary, ), @@ -437,7 +437,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> { .whereType>() .toList(); final gstOptions = [ - const AppDropdownOption(value: null, label: 'Select GST rate'), + const AppDropdownOption(value: null, label: 'Select GST Rate'), ...widget.gstRates.map((e) { final id = _parseId(e.id); if (id == null) return null; diff --git a/lib/modules/rbac/presentation/providers/rbac_provider.dart b/lib/modules/rbac/presentation/providers/rbac_provider.dart index 181b945..e2a7003 100644 --- a/lib/modules/rbac/presentation/providers/rbac_provider.dart +++ b/lib/modules/rbac/presentation/providers/rbac_provider.dart @@ -11,9 +11,9 @@ class RbacState { this.selectedTab = RbacTab.users, this.selectedRoleId = 'super_admin', this.searchQuery = '', - this.roleFilter = 'All roles', - this.departmentFilter = 'All departments', - this.statusFilter = 'All statuses', + this.roleFilter = 'All Roles', + this.departmentFilter = 'All Departments', + this.statusFilter = 'All Statuses', this.currentPage = 0, this.pageSize = 10, }); @@ -45,10 +45,10 @@ class RbacState { user.email.toLowerCase().contains(q) || user.employeeCode.toLowerCase().contains(q); final matchesRole = - roleFilter == 'All roles' || user.roleName == roleFilter; - final matchesDept = departmentFilter == 'All departments' || + roleFilter == 'All Roles' || user.roleName == roleFilter; + final matchesDept = departmentFilter == 'All Departments' || user.department == departmentFilter; - final matchesStatus = statusFilter == 'All statuses' || + final matchesStatus = statusFilter == 'All Statuses' || user.status.label == statusFilter; return matchesSearch && matchesRole && matchesDept && matchesStatus; }).toList(); 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 bba8140..45bda0c 100644 --- a/lib/modules/rbac/presentation/screens/users_role_management_screen.dart +++ b/lib/modules/rbac/presentation/screens/users_role_management_screen.dart @@ -15,6 +15,7 @@ import '../../../users/presentation/widgets/user_rich_data_table.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_card.dart'; +import '../../../../shared/widgets/app_data_table.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/error_view.dart'; @@ -176,7 +177,7 @@ class _UsersRoleManagementScreenState SizedBox( width: cardWidth.clamp(160, constraints.maxWidth), child: RbacStatCard( - label: 'Total users', + label: 'Total Users', value: '${summary?.totalUsers ?? usersState?.total ?? 0}', icon: Icons.people_outline, color: Color(0xFF2563EB), @@ -212,7 +213,7 @@ class _UsersRoleManagementScreenState SizedBox( width: cardWidth.clamp(160, constraints.maxWidth), child: RbacStatCard( - label: 'Roles defined', + label: 'Roles Defined', value: '${summary?.rolesCount ?? state.roles.length}', icon: Icons.shield_outlined, color: const Color(0xFF16A34A), @@ -558,13 +559,13 @@ class _UsersTabState extends ConsumerState<_UsersTab> { final password = await showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Reset password'), + title: const Text('Reset Password'), content: TextField( controller: controller, obscureText: true, autofocus: true, decoration: const InputDecoration( - labelText: 'New temporary password', + labelText: 'New Temporary Password', hintText: 'Min. 8 characters', ), ), @@ -674,27 +675,27 @@ class _UsersTabState extends ConsumerState<_UsersTab> { final canExport = ref.can('users', PermissionAction.export); final canEditUser = ref.can('users', PermissionAction.update); final canDeleteUser = ref.can('users', PermissionAction.delete); - final roles = ['All roles', ...?filters?.roles.map((r) => r.name)]; + final roles = ['All Roles', ...?filters?.roles.map((r) => r.name)]; final departments = [ - 'All departments', + 'All Departments', ...?filters?.departments.map((d) => d.name), ]; final statuses = [ - 'All statuses', + 'All Statuses', ...?filters?.statuses.map((s) => s.name), ]; final roleFilter = - _roleNameForId(usersState.query.roleId, filters) ?? 'All roles'; + _roleNameForId(usersState.query.roleId, filters) ?? 'All Roles'; final departmentFilter = _departmentNameForId( usersState.query.departmentId, filters, ) ?? - 'All departments'; + 'All Departments'; final statusFilter = _statusLabelForValue( usersState.query.status, filters, ) ?? - 'All statuses'; + 'All Statuses'; final page = usersState.query.page; final pageSize = usersState.query.limit; final total = usersState.total; @@ -731,21 +732,21 @@ class _UsersTabState extends ConsumerState<_UsersTab> { onSearch: ref.read(usersListProvider.notifier).setSearch, onRoleChanged: (value) { ref.read(usersListProvider.notifier).setRoleFilter( - value == 'All roles' + value == 'All Roles' ? null : _roleIdForName(value, filters), ); }, onDepartmentChanged: (value) { ref.read(usersListProvider.notifier).setDepartmentFilter( - value == 'All departments' + value == 'All Departments' ? null : _departmentIdForName(value, filters), ); }, onStatusChanged: (value) { ref.read(usersListProvider.notifier).setStatusFilter( - value == 'All statuses' + value == 'All Statuses' ? null : _statusValueForLabel(value, filters), ); @@ -994,138 +995,204 @@ class _RolesTab extends ConsumerWidget { ), ), data: (rolesState) { - final roles = rolesState.roles; + final roles = rolesState.pagedRoles; + final notifier = ref.read(rolesListProvider.notifier); - return GridView.builder( - key: ValueKey('$themeMode-$brightness'), - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 320, - mainAxisExtent: 190, - crossAxisSpacing: 16, - mainAxisSpacing: 16, + 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), + ), ), - itemCount: roles.length + (canCreateRole ? 1 : 0), - itemBuilder: (context, index) { - if (canCreateRole && index == roles.length) { - return AppHoverEffect( - onTap: onNewRole, - showHoverBorder: false, - child: CustomPaint( - painter: _DashedBorderPainter( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), - radius: 12, + child: Column( + children: [ + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + key: ValueKey('$themeMode-$brightness-${rolesState.page}'), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 320, + mainAxisExtent: 168, + crossAxisSpacing: 16, + mainAxisSpacing: 16, ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add, - size: 32, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 8), - Text( - 'New role', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } - - final role = roles[index]; - final appearance = roleCardAppearance(index); - - return AppCard( - elevation: 0, - enableHover: true, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: appearance.color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), + itemCount: roles.length + (canCreateRole ? 1 : 0), + itemBuilder: (context, index) { + if (canCreateRole && index == 0) { + return AppHoverEffect( + onTap: onNewRole, + showHoverBorder: false, + child: CustomPaint( + painter: _DashedBorderPainter( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + radius: 12, ), - child: Icon(appearance.icon, color: appearance.color, size: 20), - ), - const Spacer(), - if (canEditRole) - IconButton( - icon: const Icon(Icons.edit_outlined, size: 18), - onPressed: () => onEditRole(role), - visualDensity: VisualDensity.compact, - ), - if (canDeleteRole && !isProtectedRole(role)) - IconButton( - icon: const Icon(Icons.delete_outline, size: 18), - onPressed: () => onDeleteRole(role), - visualDensity: VisualDensity.compact, - ), - ], - ), - const SizedBox(height: 8), - Text( - role.name, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Expanded( - child: Text( - role.description ?? '—', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Row( - children: [ - Icon( - Icons.people_outline, - size: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${role.userCount} users', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add, + size: 32, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), - ), - const SizedBox(width: 16), - Icon( - Icons.vpn_key_outlined, - size: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - '${role.permissionCount} permissions', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + Text( + 'New role', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), + ], + ), ), - ], - ), - ], + ); + } + + final roleIndex = canCreateRole ? index - 1 : index; + final role = roles[roleIndex]; + final appearance = roleCardAppearance(roleIndex); + final description = role.description?.trim(); + final displayDescription = + (description == null || description.isEmpty) + ? '—' + : description; + + return AppCard( + elevation: 0, + enableHover: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: appearance.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + appearance.icon, + color: appearance.color, + size: 20, + ), + ), + const Spacer(), + if (canEditRole) + IconButton( + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => onEditRole(role), + visualDensity: VisualDensity.compact, + ), + if (canDeleteRole && !isProtectedRole(role)) + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => onDeleteRole(role), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 8), + Text( + role.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Tooltip( + message: displayDescription == '—' ? '' : displayDescription, + waitDuration: const Duration(milliseconds: 300), + child: Text( + displayDescription, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + height: 1.35, + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + Icons.people_outline, + size: 14, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${role.userCount} users', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(width: 16), + Icon( + Icons.vpn_key_outlined, + size: 14, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${role.permissionCount} permissions', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ); + }, ), ), - ); - }, + const Divider(height: 1), + AppPagination( + currentPage: rolesState.page, + totalPages: rolesState.totalPages, + totalItems: rolesState.total, + pageSize: rolesState.limit, + itemLabel: 'roles', + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + onPageChanged: notifier.setPage, + onPageSizeChanged: notifier.setPageSize, + ), + ], + ), ); }, ); @@ -1345,141 +1412,76 @@ class _PermissionMatrixTable extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); 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 AppDataTable( + wrapInCard: false, + columns: [ + AppDataColumn( + label: 'Module', + flex: 3, + cellBuilder: (context, module) { + final index = matrix.modules.indexOf(module); + final appearance = + permissionModuleAppearance(module.code, index); - 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), + return Row( + children: [ + 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, ), ), - children: [ - TableRow( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.4), + const SizedBox(width: 10), + Expanded( + child: AppTableCell.text( + module.name, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, ), - children: [ - const _MatrixHeader('MODULE'), - ...actionColumns.map( - (action) => _MatrixHeader(permissionActionLabel(action)), - ), - ], ), - ...matrix.modules.asMap().entries.map((entry) { - final index = entry.key; - final module = entry.value; - final appearance = - permissionModuleAppearance(module.code, index); - - return TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - 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, - ), - ), - 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, - ), - ), - ), - ); - }), - ], - ); - }), - ], - ), - ), + ), + ], + ); + }, + ), + ...actionColumns.map( + (action) => AppDataColumn( + label: permissionActionLabel(action), + flex: 1, + alignment: Alignment.center, + cellBuilder: (context, module) { + final checked = module.granted[action] ?? false; + return Checkbox( + value: checked, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (value) => ref + .read(permissionMatrixProvider(roleId).notifier) + .toggleAction( + module.moduleId, + action, + value ?? false, + ), + ); + }, ), - ); - }, - ); - } -} - -class _MatrixHeader extends StatelessWidget { - const _MatrixHeader(this.label); - - final String label; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), - child: Text( - label, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), + ), + ], + rows: matrix.modules, ); } } diff --git a/lib/modules/rbac/presentation/widgets/add_user_panel.dart b/lib/modules/rbac/presentation/widgets/add_user_panel.dart index 25f36fa..e295e14 100644 --- a/lib/modules/rbac/presentation/widgets/add_user_panel.dart +++ b/lib/modules/rbac/presentation/widgets/add_user_panel.dart @@ -228,7 +228,7 @@ class _AddUserPanelState extends ConsumerState { final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; return SidePanelScaffold( - title: widget.isEditing ? 'Edit user' : 'Add user', + title: widget.isEditing ? 'Edit User' : 'Add User', footer: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -269,16 +269,16 @@ class _AddUserPanelState extends ConsumerState { SidePanelFormRow( left: AppTextField( controller: _nameController, - label: 'Full name *', + label: 'Full Name *', hint: 'e.g. Ravi Kumar', validator: (v) => Validators.required(v, fieldName: 'Name'), ), right: AppTextField( controller: _employeeCodeController, - label: 'Employee code *', + label: 'Employee Code *', hint: 'e.g. EMP002', validator: (v) => - Validators.required(v, fieldName: 'Employee code'), + Validators.required(v, fieldName: 'Employee Code'), ), ), SidePanelFormRow( @@ -360,7 +360,7 @@ class _AddUserPanelState extends ConsumerState { onChanged: (v) => setState(() => _selectedPlantId = v), ), right: _buildDropdown( - label: 'Reporting to', + label: 'Reporting To', value: _selectedReportingToId, options: formState.managers, hint: formState.managers.isEmpty @@ -378,8 +378,8 @@ class _AddUserPanelState extends ConsumerState { AppTextField( controller: _passwordController, label: widget.isEditing - ? 'New password' - : 'Temporary password *', + ? 'New Password' + : 'Temporary Password *', hint: 'Min. 8 characters', obscureText: true, validator: widget.isEditing diff --git a/lib/modules/rbac/presentation/widgets/create_role_panel.dart b/lib/modules/rbac/presentation/widgets/create_role_panel.dart index 3b15396..b11d7ad 100644 --- a/lib/modules/rbac/presentation/widgets/create_role_panel.dart +++ b/lib/modules/rbac/presentation/widgets/create_role_panel.dart @@ -98,7 +98,7 @@ class _RoleFormPanelState extends ConsumerState { final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; return SidePanelScaffold( - title: widget.isEditing ? 'Edit role' : 'Create new role', + title: widget.isEditing ? 'Edit Role' : 'Create New Role', footer: Row( children: [ Expanded( @@ -157,7 +157,7 @@ class _RoleFormPanelState extends ConsumerState { children: [ AppTextField( controller: _nameController, - label: 'Role name *', + label: 'Role Name *', hint: 'e.g. QC Manager', validator: Validators.roleName, inputFormatters: Validators.roleNameInput, diff --git a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart index 81d6401..b6182b0 100644 --- a/lib/modules/rbac/presentation/widgets/rbac_widgets.dart +++ b/lib/modules/rbac/presentation/widgets/rbac_widgets.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../../../shared/widgets/app_data_table.dart'; +import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/widgets/app_card.dart'; @@ -212,24 +213,41 @@ class UserRolesCell extends StatelessWidget { class EmployeeCodeBadge extends StatelessWidget { const EmployeeCodeBadge({super.key, required this.code}); + /// Uniform width for employee code pills in data tables. + static const double tableWidth = 108; + final String code; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(20), - ), - child: AppTableCell.text( - code, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurfaceVariant, + final display = code.trim().isEmpty ? '—' : code.trim(); + + return SizedBox( + width: tableWidth, + child: Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: Tooltip( + message: display == '—' ? '' : display, + child: Text( + display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant, + height: 1, + ), + ), + ), ), - showTooltip: true, ), ); } @@ -342,26 +360,24 @@ class UserTableActions extends StatelessWidget { Widget build(BuildContext context) { final muted = Theme.of(context).colorScheme.onSurfaceVariant; - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, + return AppTableActions( children: [ if (canEdit) - _UserActionIcon( + AppTableActionIcon( tooltip: 'Edit user', icon: Icons.edit_outlined, color: muted, onPressed: onEdit, ), if (canResetPassword) - _UserActionIcon( + AppTableActionIcon( tooltip: 'Reset password', icon: Icons.vpn_key_outlined, color: muted, onPressed: onResetPassword, ), if (canDeactivate) - _UserActionIcon( + AppTableActionIcon( tooltip: 'Deactivate user', icon: Icons.person_off_outlined, color: muted, @@ -407,35 +423,6 @@ class UserTableActionsCell extends StatelessWidget { } } -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, diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index d8d0be1..60b63ec 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -80,7 +80,7 @@ class _DepreciationReportScreenState initialDate: query.asOfDate ?? now, firstDate: DateTime(now.year - 20), lastDate: DateTime(now.year + 1), - helpText: 'As of date', + helpText: 'As Of Date', ); if (picked == null) return; ref.read(depreciationReportProvider.notifier).setAsOfDate(picked); @@ -165,7 +165,7 @@ class _DepreciationReportScreenState ], ), _SummaryStrip(summary: state.summary, asOfDate: state.asOfDate), - const SizedBox(height: 16), + const SizedBox(height: 12), Expanded( child: AppTableShell( toolbar: LayoutBuilder( @@ -496,7 +496,7 @@ class _FiltersBarState extends State<_FiltersBar> { final asOfEmpty = query.asOfDate == null; final asOfField = AppFilterDateField( - label: 'As of date', + label: 'As Of Date', value: asOfEmpty ? '' : DateFormatter.displayDate(query.asOfDate), placeholder: 'Select date', icon: Icons.calendar_today_outlined, @@ -508,7 +508,7 @@ class _FiltersBarState extends State<_FiltersBar> { final purchaseEmpty = query.purchaseDateFrom == null && query.purchaseDateTo == null; final purchaseField = AppFilterDateField( - label: 'Purchase dates', + label: 'Purchase Dates', value: purchaseEmpty ? '' : '${DateFormatter.displayDate(query.purchaseDateFrom)} – ${DateFormatter.displayDate(query.purchaseDateTo)}', diff --git a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart index 9779856..2264464 100644 --- a/lib/modules/roles/presentation/screens/permission_matrix_screen.dart +++ b/lib/modules/roles/presentation/screens/permission_matrix_screen.dart @@ -64,7 +64,6 @@ class _PermissionMatrixScreenState extends ConsumerState ), ], ), - const SizedBox(height: 16), Expanded( child: context.isMobile ? _MatrixCardList(roleId: widget.roleId, matrix: matrix) diff --git a/lib/modules/roles/presentation/screens/role_list_screen.dart b/lib/modules/roles/presentation/screens/role_list_screen.dart index 66ca9c8..1c6286c 100644 --- a/lib/modules/roles/presentation/screens/role_list_screen.dart +++ b/lib/modules/roles/presentation/screens/role_list_screen.dart @@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_table_shell.dart'; +import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/page_header.dart'; import '../providers/roles_provider.dart'; @@ -54,7 +55,6 @@ class _RoleListScreenState extends ConsumerState { title: 'Roles', subtitle: 'Manage roles and permission assignments', ), - const SizedBox(height: 16), Expanded( child: AppTableShell( toolbar: SizedBox( @@ -135,12 +135,14 @@ class _RoleDataTable extends StatelessWidget { label: 'Actions', flex: 1, alignment: Alignment.centerRight, - cellBuilder: (_, r) => Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => onOpen(r), - child: const Text('View Matrix'), - ), + cellBuilder: (_, r) => AppTableActions( + children: [ + AppTableActionIcon( + tooltip: 'View Matrix', + icon: Icons.grid_view_outlined, + onPressed: () => onOpen(r), + ), + ], ), ), ], diff --git a/lib/modules/settings/data/datasources/settings_remote_data_source.dart b/lib/modules/settings/data/datasources/settings_remote_data_source.dart index c953c51..bac069b 100644 --- a/lib/modules/settings/data/datasources/settings_remote_data_source.dart +++ b/lib/modules/settings/data/datasources/settings_remote_data_source.dart @@ -17,9 +17,12 @@ class SettingsRemoteDataSource { if (data is Map) return Map.from(data); // Some responses return the entity at the root alongside success/message. if (root.containsKey('org_name') || + root.containsKey('gstin') || root.containsKey('smtp_host') || root.containsKey('logo_url') || - root.containsKey('logo')) { + root.containsKey('logo') || + root.containsKey('favicon_url') || + root.containsKey('favicon')) { return root; } return null; @@ -87,6 +90,53 @@ class SettingsRemoteDataSource { return null; } + /// POST `/settings/company/favicon` (multipart field `favicon`) + Future uploadCompanyFavicon(List bytes, String filename) async { + final formData = FormData.fromMap({ + 'favicon': MultipartFile.fromBytes(bytes, filename: filename), + }); + final response = await _dio.post( + ApiEndpoints.settingsCompanyFavicon, + data: formData, + ); + return _extractFaviconUrl(response.data); + } + + String? _extractFaviconUrl(dynamic responseData) { + if (responseData is String && responseData.trim().isNotEmpty) { + return resolveMediaUrl(responseData); + } + if (responseData is! Map) return null; + final root = Map.from(responseData); + + String? fromMap(Map map) { + for (final key in [ + 'favicon_url', + 'faviconUrl', + 'favicon', + 'url', + 'path', + ]) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) { + return resolveMediaUrl(value); + } + } + return null; + } + + final data = root['data']; + if (data is Map) { + final nested = Map.from(data); + final found = fromMap(nested); + if (found != null) return found; + } + if (data is String && data.trim().isNotEmpty) { + return resolveMediaUrl(data); + } + return fromMap(root); + } + /// GET `/settings/email` Future fetchEmail() async { final response = await _dio.get(ApiEndpoints.settingsEmail); diff --git a/lib/modules/settings/data/repositories/settings_repository_impl.dart b/lib/modules/settings/data/repositories/settings_repository_impl.dart index decd991..30eee1a 100644 --- a/lib/modules/settings/data/repositories/settings_repository_impl.dart +++ b/lib/modules/settings/data/repositories/settings_repository_impl.dart @@ -58,6 +58,7 @@ class SettingsRepositoryImpl implements SettingsRepository { final merged = (current?.companyProfile ?? const CompanyProfileSettings()) .copyWith( companyName: remoteProfile.companyName, + gstNumber: remoteProfile.gstNumber, address: remoteProfile.address, city: remoteProfile.city, state: remoteProfile.state, @@ -68,6 +69,9 @@ class SettingsRepositoryImpl implements SettingsRepository { logoUrl: remoteProfile.logoUrl.isNotEmpty ? remoteProfile.logoUrl : current?.companyProfile.logoUrl ?? '', + faviconUrl: remoteProfile.faviconUrl.isNotEmpty + ? remoteProfile.faviconUrl + : current?.companyProfile.faviconUrl ?? '', ); await local.write((current ?? const AppSettings()).copyWith( companyProfile: merged, @@ -97,7 +101,12 @@ class SettingsRepositoryImpl implements SettingsRepository { email: saved.email.isNotEmpty ? saved.email : profile.email, phone: saved.phone.isNotEmpty ? saved.phone : profile.phone, website: saved.website.isNotEmpty ? saved.website : profile.website, + gstNumber: saved.gstNumber.isNotEmpty + ? saved.gstNumber + : profile.gstNumber, logoUrl: saved.logoUrl.isNotEmpty ? saved.logoUrl : profile.logoUrl, + faviconUrl: + saved.faviconUrl.isNotEmpty ? saved.faviconUrl : profile.faviconUrl, ); await local.write(current.copyWith(companyProfile: merged)); return merged; @@ -112,6 +121,16 @@ class SettingsRepositoryImpl implements SettingsRepository { return safeApiCall(() => remote.uploadCompanyLogo(bytes, filename)); } + @override + Future> uploadCompanyFavicon( + List bytes, + String filename, + ) async { + return safeApiCall( + () => remote.uploadCompanyFavicon(bytes, filename), + ); + } + @override Future> fetchEmailSettings() async { return safeApiCall(() async { diff --git a/lib/modules/settings/domain/entities/app_settings.dart b/lib/modules/settings/domain/entities/app_settings.dart index 2b5beda..2c36733 100644 --- a/lib/modules/settings/domain/entities/app_settings.dart +++ b/lib/modules/settings/domain/entities/app_settings.dart @@ -150,6 +150,7 @@ class CompanyProfileSettings { /// Payload for `PUT /settings/company` ([CompanySettingsBody]). Map toApiJson() => { 'org_name': companyName, + 'gstin': gstNumber, 'mobile': phone, 'email': email, 'website': website, @@ -169,7 +170,9 @@ class CompanyProfileSettings { '', companyCode: json['companyCode'] as String? ?? '', registrationNumber: json['registrationNumber'] as String? ?? '', - gstNumber: json['gstNumber'] as String? ?? '', + gstNumber: json['gstin'] as String? ?? + json['gstNumber'] as String? ?? + '', address: json['address'] as String? ?? '', city: json['city'] as String? ?? '', state: json['state'] as String? ?? '', @@ -183,7 +186,12 @@ class CompanyProfileSettings { json['logo'] as String?, ) ?? '', - faviconUrl: resolveMediaUrl(json['faviconUrl'] as String?) ?? '', + faviconUrl: resolveMediaUrl( + json['favicon_url'] as String? ?? + json['faviconUrl'] as String? ?? + json['favicon'] as String?, + ) ?? + '', ); } diff --git a/lib/modules/settings/domain/repositories/settings_repository.dart b/lib/modules/settings/domain/repositories/settings_repository.dart index 582e6ef..0ead325 100644 --- a/lib/modules/settings/domain/repositories/settings_repository.dart +++ b/lib/modules/settings/domain/repositories/settings_repository.dart @@ -12,6 +12,10 @@ abstract class SettingsRepository { List bytes, String filename, ); + Future> uploadCompanyFavicon( + List bytes, + String filename, + ); Future> fetchEmailSettings(); Future> saveEmailSettings( EmailConfigurationSettings email, diff --git a/lib/modules/settings/presentation/providers/settings_provider.dart b/lib/modules/settings/presentation/providers/settings_provider.dart index a28a692..9e4385f 100644 --- a/lib/modules/settings/presentation/providers/settings_provider.dart +++ b/lib/modules/settings/presentation/providers/settings_provider.dart @@ -6,6 +6,7 @@ import '../../../../core/network/dio_client.dart'; import '../../../../core/theme/branding_config.dart'; import '../../../../core/theme/theme_provider.dart'; import '../../../../core/utils/favicon_store.dart'; +import '../../../../core/utils/favicon_updater.dart'; import '../../../../core/utils/media_url.dart'; import '../../data/datasources/settings_local_data_source.dart'; import '../../data/datasources/settings_remote_data_source.dart'; @@ -92,10 +93,19 @@ class AppSettingsNotifier extends StateNotifier { ); } + Future _syncFavicon(String? faviconUrl) async { + final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl?.trim(); + final value = (resolved == null || resolved.isEmpty) ? null : resolved; + await _faviconStore.write(value); + if (value != null) { + updateFavicon(value); + } + } + Future _load() async { final result = await _getSettings(); state = result.data ?? const AppSettings(); - _faviconStore.apply(); + await _syncFavicon(state.companyProfile.faviconUrl); await _syncMainLogo(state.companyProfile); } @@ -109,6 +119,7 @@ class AppSettingsNotifier extends StateNotifier { if (result.failure == null && result.data != null) { state = state.copyWith(companyProfile: result.data!); await _syncMainLogo(result.data!); + await _syncFavicon(result.data!.faviconUrl); } return result.failure; } finally { @@ -146,6 +157,7 @@ class AppSettingsNotifier extends StateNotifier { state = state.copyWith(companyProfile: result.data!); await _saveSettings(state); await _syncMainLogo(result.data!); + await _syncFavicon(result.data!.faviconUrl); } return null; } @@ -167,6 +179,25 @@ class AppSettingsNotifier extends StateNotifier { return result; } + /// POST `/settings/company/favicon` — also updates the browser tab icon. + Future> uploadCompanyFavicon( + List bytes, + String filename, + ) async { + final result = await _repository.uploadCompanyFavicon(bytes, filename); + if (result.failure == null && + result.data != null && + result.data!.isNotEmpty) { + final faviconUrl = resolveMediaUrl(result.data!) ?? result.data!; + final profile = state.companyProfile.copyWith(faviconUrl: faviconUrl); + state = state.copyWith(companyProfile: profile); + await _saveSettings(state); + await _syncFavicon(faviconUrl); + return (data: faviconUrl, failure: null); + } + return result; + } + /// Applies a local/preview logo to company profile + main app branding. Future applyLocalCompanyLogo(String logoUrl) async { final resolved = resolveMediaUrl(logoUrl) ?? logoUrl; @@ -176,6 +207,15 @@ class AppSettingsNotifier extends StateNotifier { await _syncMainLogo(profile); } + /// Applies a local/preview favicon when upload is unavailable. + Future applyLocalCompanyFavicon(String faviconUrl) async { + final resolved = resolveMediaUrl(faviconUrl) ?? faviconUrl; + final profile = state.companyProfile.copyWith(faviconUrl: resolved); + state = state.copyWith(companyProfile: profile); + await _saveSettings(state); + await _syncFavicon(resolved); + } + Future updateUiPreferences(UiPreferencesSettings prefs) async { await _persist(state.copyWith(uiPreferences: prefs)); } diff --git a/lib/modules/settings/presentation/screens/appearance_settings_screen.dart b/lib/modules/settings/presentation/screens/appearance_settings_screen.dart index 04fb70f..802d3f9 100644 --- a/lib/modules/settings/presentation/screens/appearance_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/appearance_settings_screen.dart @@ -128,7 +128,7 @@ class AppearanceSettingsScreen extends ConsumerWidget { title: 'UI Preferences', children: [ SettingsSwitchTile( - title: 'Sidebar Expanded by Default', + title: 'Sidebar Expanded By Default', subtitle: 'Show full sidebar labels on login', value: uiPrefs.sidebarExpanded, onChanged: (v) => ref diff --git a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart index 1bef63e..dd9060a 100644 --- a/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/company_profile_settings_screen.dart @@ -45,6 +45,7 @@ class _CompanyProfileSettingsScreenState bool _loading = true; bool _saving = false; bool _uploadingLogo = false; + bool _uploadingFavicon = false; @override void initState() { @@ -173,11 +174,6 @@ class _CompanyProfileSettingsScreenState ), ); - await FaviconStore(ref.read(sharedPreferencesProvider)).write( - faviconUrl.isEmpty ? null : faviconUrl, - ); - updateFavicon(faviconUrl.isEmpty ? null : faviconUrl); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Company profile saved')), @@ -185,27 +181,6 @@ class _CompanyProfileSettingsScreenState } } - Future _pickImage(void Function(String dataUri) onPicked) async { - final result = await FilePicker.pickFiles( - type: FileType.image, - withData: true, - ); - - if (result == null || result.files.isEmpty) return; - - final file = result.files.single; - final bytes = file.bytes; - if (bytes == null) return; - - final ext = (file.extension ?? 'png').toLowerCase(); - final mime = ext == 'jpg' ? 'jpeg' : ext; - final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}'; - - setState(() { - onPicked(dataUri); - }); - } - Future _pickLogo() async { final result = await FilePicker.pickFiles( type: FileType.image, @@ -256,9 +231,68 @@ class _CompanyProfileSettingsScreenState setState(() => _logoUrlController.text = dataUri); } - Future _pickFavicon() => _pickImage((dataUri) { - _faviconUrlController.text = dataUri; - }); + Future _pickFavicon() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'ico'], + withData: true, + ); + + if (result == null || result.files.isEmpty) return; + + final file = result.files.single; + final bytes = file.bytes; + if (bytes == null) return; + + setState(() => _uploadingFavicon = true); + final uploadResult = + await ref.read(appSettingsProvider.notifier).uploadCompanyFavicon( + bytes, + file.name, + ); + if (!mounted) return; + setState(() => _uploadingFavicon = false); + + final uploadedUrl = uploadResult.data; + if (uploadResult.failure == null && + uploadedUrl != null && + uploadedUrl.isNotEmpty) { + // Apply uploaded bytes immediately so the tab icon updates without waiting + // on cross-origin image fetch / browser favicon cache. + final ext = (file.extension ?? 'png').toLowerCase(); + final mime = ext == 'jpg' + ? 'jpeg' + : (ext == 'ico' ? 'x-icon' : ext); + final previewDataUri = + 'data:image/$mime;base64,${base64Encode(bytes)}'; + updateFavicon(previewDataUri); + + setState(() => _faviconUrlController.text = uploadedUrl); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Favicon uploaded')), + ); + return; + } + + if (uploadResult.failure != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(uploadResult.failure!.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + // Local preview fallback when upload is unavailable. + final ext = (file.extension ?? 'png').toLowerCase(); + final mime = ext == 'jpg' ? 'jpeg' : (ext == 'ico' ? 'x-icon' : ext); + final dataUri = 'data:image/$mime;base64,${base64Encode(bytes)}'; + await ref + .read(appSettingsProvider.notifier) + .applyLocalCompanyFavicon(dataUri); + if (!mounted) return; + setState(() => _faviconUrlController.text = dataUri); + } @override Widget build(BuildContext context) { @@ -281,7 +315,7 @@ class _CompanyProfileSettingsScreenState controller: _nameController, label: 'Company Name', validator: (v) => - Validators.required(v, fieldName: 'Company name'), + Validators.required(v, fieldName: 'Company Name'), ), const SizedBox(height: 16), AppTextField( @@ -296,7 +330,7 @@ class _CompanyProfileSettingsScreenState const SizedBox(height: 16), AppTextField( controller: _gstController, - label: 'GST/VAT Number', + label: 'GSTIN', validator: Validators.optionalGstin, inputFormatters: Validators.gstinInput, ), @@ -333,7 +367,7 @@ class _CompanyProfileSettingsScreenState const SizedBox(height: 16), AppTextField( controller: _phoneController, - label: 'Phone', + label: 'Mobile', keyboardType: TextInputType.phone, validator: Validators.optionalMobile, inputFormatters: Validators.mobileInput, @@ -388,7 +422,7 @@ class _CompanyProfileSettingsScreenState SettingsFormCard( title: 'Favicon Upload', subtitle: - 'Upload an image or provide a favicon URL for the browser tab', + 'Upload JPEG, PNG, WebP, or ICO for the browser tab icon', children: [ Center( child: SidebarLogo( @@ -409,9 +443,18 @@ class _CompanyProfileSettingsScreenState ), const SizedBox(height: 12), OutlinedButton.icon( - onPressed: _pickFavicon, - icon: const Icon(Icons.upload_file), - label: const Text('Upload Favicon'), + onPressed: _uploadingFavicon ? null : _pickFavicon, + icon: _uploadingFavicon + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file), + label: Text( + _uploadingFavicon ? 'Uploading…' : 'Upload Favicon', + ), ), ], ), diff --git a/lib/modules/settings/presentation/screens/email_configuration_screen.dart b/lib/modules/settings/presentation/screens/email_configuration_screen.dart index ad9ede1..1d2a074 100644 --- a/lib/modules/settings/presentation/screens/email_configuration_screen.dart +++ b/lib/modules/settings/presentation/screens/email_configuration_screen.dart @@ -145,7 +145,7 @@ class _EmailConfigurationScreenState label: 'SMTP Host', hint: 'smtp.gmail.com', validator: (v) => - Validators.required(v, fieldName: 'SMTP host'), + Validators.required(v, fieldName: 'SMTP Host'), ), const SizedBox(height: 16), AppTextField( @@ -153,7 +153,7 @@ class _EmailConfigurationScreenState label: 'SMTP Port', keyboardType: TextInputType.number, validator: (v) => - Validators.required(v, fieldName: 'SMTP port'), + Validators.required(v, fieldName: 'SMTP Port'), ), const SizedBox(height: 16), AppTextField( diff --git a/lib/modules/settings/presentation/screens/security_settings_screen.dart b/lib/modules/settings/presentation/screens/security_settings_screen.dart index 7c3a16f..d5bbd9c 100644 --- a/lib/modules/settings/presentation/screens/security_settings_screen.dart +++ b/lib/modules/settings/presentation/screens/security_settings_screen.dart @@ -140,7 +140,7 @@ class _SecuritySettingsScreenState extends ConsumerState children: [ AppTextField( controller: _sessionTimeoutController, - label: 'Session Timeout (minutes)', + label: 'Session Timeout (Minutes)', keyboardType: TextInputType.number, ), SettingsSwitchTile( @@ -167,7 +167,7 @@ class _SecuritySettingsScreenState extends ConsumerState const SizedBox(height: 16), AppTextField( controller: _lockDurationController, - label: 'Account Lock Duration (minutes)', + label: 'Account Lock Duration (Minutes)', keyboardType: TextInputType.number, ), const SizedBox(height: 16), diff --git a/lib/modules/users/presentation/screens/user_form_screen.dart b/lib/modules/users/presentation/screens/user_form_screen.dart index 6445987..fea4b92 100644 --- a/lib/modules/users/presentation/screens/user_form_screen.dart +++ b/lib/modules/users/presentation/screens/user_form_screen.dart @@ -175,13 +175,13 @@ class _UserFormScreenState extends ConsumerState { AppTextField( controller: _firstNameController, label: 'First Name', - validator: (v) => Validators.required(v, fieldName: 'First name'), + validator: (v) => Validators.required(v, fieldName: 'First Name'), ), const SizedBox(height: 16), AppTextField( controller: _lastNameController, label: 'Last Name', - validator: (v) => Validators.required(v, fieldName: 'Last name'), + validator: (v) => Validators.required(v, fieldName: 'Last Name'), ), const SizedBox(height: 16), AppTextField( diff --git a/lib/modules/users/presentation/screens/user_list_screen.dart b/lib/modules/users/presentation/screens/user_list_screen.dart index 9339100..156a166 100644 --- a/lib/modules/users/presentation/screens/user_list_screen.dart +++ b/lib/modules/users/presentation/screens/user_list_screen.dart @@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_status_chip.dart'; import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/kpi_card.dart'; import '../../../../shared/widgets/app_table_shell.dart'; +import '../../../../shared/widgets/app_table_action_icon.dart'; import '../../../../shared/widgets/page_header.dart'; import '../widgets/user_rich_data_table.dart'; import '../providers/users_provider.dart'; @@ -65,10 +66,9 @@ class _UserListScreenState extends ConsumerState { ], ), if (state.summary != null) ...[ - const SizedBox(height: 16), + const SizedBox(height: 12), _SummaryStrip(summary: state.summary!), ], - const SizedBox(height: 16), Expanded( child: AppTableShell( toolbar: _FiltersBar( @@ -231,7 +231,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search status...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All statuses'), + const AppDropdownOption(value: null, label: 'All Statuses'), ...(filters?.statuses ?? []) .map((s) => AppDropdownOption(value: s.id, label: s.name)), ], @@ -243,7 +243,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search role...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All roles'), + const AppDropdownOption(value: null, label: 'All Roles'), ...(filters?.roles ?? []).map( (r) => AppDropdownOption( value: int.tryParse(r.id), @@ -259,7 +259,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search department...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All departments'), + const AppDropdownOption(value: null, label: 'All Departments'), ...(filters?.departments ?? []).map( (d) => AppDropdownOption( value: int.tryParse(d.id), @@ -413,27 +413,31 @@ class _UserActions extends StatelessWidget { @override Widget build(BuildContext context) { - return Wrap( - spacing: 4, + final errorColor = Theme.of(context).colorScheme.error; + + return AppTableActions( children: [ - IconButton( + AppTableActionIcon( tooltip: 'View', - icon: const Icon(Icons.visibility_outlined), + icon: Icons.visibility_outlined, onPressed: () => onView(user), ), - IconButton( + AppTableActionIcon( tooltip: 'Edit', - icon: const Icon(Icons.edit_outlined), + icon: Icons.edit_outlined, onPressed: () => onEdit(user), ), - IconButton( + AppTableActionIcon( tooltip: user.status == 'active' ? 'Deactivate' : 'Activate', - icon: Icon(user.status == 'active' ? Icons.pause_circle : Icons.play_circle), + icon: user.status == 'active' + ? Icons.pause_circle_outline + : Icons.play_circle_outline, onPressed: () => onToggleStatus(user), ), - IconButton( + AppTableActionIcon( tooltip: 'Delete', - icon: const Icon(Icons.delete_outline), + icon: Icons.delete_outline, + color: errorColor, onPressed: () => onDeactivate(user), ), ], diff --git a/lib/modules/users/presentation/screens/user_profile_screen.dart b/lib/modules/users/presentation/screens/user_profile_screen.dart index b635737..22abc14 100644 --- a/lib/modules/users/presentation/screens/user_profile_screen.dart +++ b/lib/modules/users/presentation/screens/user_profile_screen.dart @@ -245,7 +245,7 @@ class _UserProfileScreenState extends ConsumerState { controller: _confirmPasswordController, label: 'Confirm Password', obscureText: true, - validator: (v) => Validators.required(v, fieldName: 'Confirm password'), + validator: (v) => Validators.required(v, fieldName: 'Confirm Password'), ), const SizedBox(height: 16), AppButton( diff --git a/lib/modules/users/presentation/widgets/user_rich_data_table.dart b/lib/modules/users/presentation/widgets/user_rich_data_table.dart index 36726e3..aba61ec 100644 --- a/lib/modules/users/presentation/widgets/user_rich_data_table.dart +++ b/lib/modules/users/presentation/widgets/user_rich_data_table.dart @@ -49,11 +49,13 @@ class UserRichDataTable extends StatelessWidget { label: 'Employee Code', sortKey: 'employee_code', flex: 1, + alignment: Alignment.centerRight, cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode), ), AppDataColumn( label: 'Role', flex: 2, + padding: const EdgeInsets.only(left: 8), cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), ), AppDataColumn( @@ -61,11 +63,6 @@ class UserRichDataTable extends StatelessWidget { flex: 2, cellBuilder: (_, user) => Text(user.departmentLabel), ), - AppDataColumn( - label: 'Plant', - flex: 2, - cellBuilder: (_, user) => Text(user.plantLabel), - ), AppDataColumn( label: 'Last Login', flex: 2, @@ -79,16 +76,17 @@ class UserRichDataTable extends StatelessWidget { AppDataColumn( label: 'Status', flex: 1, - cellBuilder: (_, user) => AppStatusChip(status: user.status), + cellBuilder: (_, user) => AppStatusChip( + status: user.status, + compact: true, + forTable: true, + ), ), AppDataColumn( label: 'Actions', flex: 1, alignment: Alignment.centerRight, - cellBuilder: (context, user) => Align( - alignment: Alignment.centerRight, - child: actionsBuilder(context, user), - ), + cellBuilder: (context, user) => actionsBuilder(context, user), ), ], rows: users, diff --git a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart index db648b0..b4ee67a 100644 --- a/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_detail_screen.dart @@ -241,7 +241,7 @@ class _OverviewTab extends StatelessWidget { gstTreatmentLabel(vendor.gstTreatment), ), _VendorInfo( - 'Source of Supply', + 'Source Of Supply', vendor.sourceOfSupply ?? '—', ), _VendorInfo('GSTIN', vendor.gstin ?? '—'), diff --git a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart index f4d863f..d394541 100644 --- a/lib/modules/vendors/presentation/screens/vendor_list_screen.dart +++ b/lib/modules/vendors/presentation/screens/vendor_list_screen.dart @@ -85,7 +85,6 @@ class _VendorListScreenState extends ConsumerState { ), ], ), - const SizedBox(height: 16), Expanded( child: AppTableShell( toolbar: LayoutBuilder( @@ -204,7 +203,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search status...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All statuses'), + const AppDropdownOption(value: null, label: 'All Statuses'), ...vendorStatusOptions.map( (e) => AppDropdownOption(value: e.$1, label: e.$2), ), @@ -217,7 +216,7 @@ class _FiltersBar extends StatelessWidget { searchHint: 'Search vendor type...', isDense: true, options: [ - const AppDropdownOption(value: null, label: 'All types'), + const AppDropdownOption(value: null, label: 'All Types'), ...vendorTypeOptions.map( (e) => AppDropdownOption(value: e.$1, label: e.$2), ), @@ -295,6 +294,8 @@ class _VendorDataTable extends StatelessWidget { flex: 1, cellBuilder: (_, vendor) => AppStatusChip( status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'), + compact: true, + forTable: true, ), ), AppDataColumn( diff --git a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart index b584e97..2cc0bd6 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_form_panel.dart @@ -186,7 +186,7 @@ class _VendorFormPanelState extends ConsumerState { widget.isEditing ? ref.watch(vendorFormProvider(widget.vendorId)) : null; return SidePanelScaffold( - title: widget.isEditing ? 'Edit vendor' : 'Add vendor', + title: widget.isEditing ? 'Edit Vendor' : 'Add Vendor', footer: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -198,7 +198,7 @@ class _VendorFormPanelState extends ConsumerState { ), const SizedBox(width: 12), AppButton( - label: widget.isEditing ? 'Update vendor' : 'Save vendor', + label: widget.isEditing ? 'Update Vendor' : 'Save Vendor', expand: false, icon: Icons.check, isLoading: _isSubmitting, @@ -240,7 +240,7 @@ class _VendorFormPanelState extends ConsumerState { AppTextField( controller: _nameController, label: 'Vendor Name *', - validator: (v) => Validators.required(v, fieldName: 'Vendor name'), + validator: (v) => Validators.required(v, fieldName: 'Vendor Name'), ), const SizedBox(height: 12), AppDropdown( @@ -278,11 +278,11 @@ class _VendorFormPanelState extends ConsumerState { loading: () => const LinearProgressIndicator(), error: (_, __) => AppTextField( controller: TextEditingController(text: _sourceOfSupply ?? ''), - label: 'Source of Supply', + label: 'Source Of Supply', onChanged: (v) => _sourceOfSupply = v, ), data: (options) => AppSearchableDropdown( - label: 'Source of Supply', + label: 'Source Of Supply', value: _sourceOfSupply, searchHint: 'Search state...', options: options @@ -316,7 +316,7 @@ class _VendorFormPanelState extends ConsumerState { ), right: AppTextField( controller: _creditDaysController, - label: 'Credit Period (days)', + label: 'Credit Period (Days)', keyboardType: TextInputType.number, ), ), diff --git a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart index 5e9ab4a..26763c0 100644 --- a/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart +++ b/lib/modules/vendors/presentation/widgets/vendor_sub_resource_panels.dart @@ -147,7 +147,7 @@ class _VendorAddressPanelState extends ConsumerState { @override Widget build(BuildContext context) { return SidePanelScaffold( - title: widget.isEditing ? 'Edit address' : 'Add address', + title: widget.isEditing ? 'Edit Address' : 'Add Address', footer: _panelFooter( context, isSubmitting: _isSubmitting, @@ -289,7 +289,7 @@ class _VendorContactPanelState extends ConsumerState { @override Widget build(BuildContext context) { return SidePanelScaffold( - title: widget.isEditing ? 'Edit contact' : 'Add contact', + title: widget.isEditing ? 'Edit Contact' : 'Add Contact', footer: _panelFooter( context, isSubmitting: _isSubmitting, @@ -303,7 +303,7 @@ class _VendorContactPanelState extends ConsumerState { AppTextField( controller: _nameController, label: 'Contact Name *', - validator: (v) => Validators.required(v, fieldName: 'Contact name'), + validator: (v) => Validators.required(v, fieldName: 'Contact Name'), ), const SizedBox(height: 12), AppTextField(controller: _designationController, label: 'Designation'), @@ -326,7 +326,7 @@ class _VendorContactPanelState extends ConsumerState { const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, - title: const Text('Primary contact'), + title: const Text('Primary Contact'), value: _isPrimary, onChanged: (v) => setState(() => _isPrimary = v), ), @@ -437,7 +437,7 @@ class _VendorBankDetailPanelState extends ConsumerState { @override Widget build(BuildContext context) { return SidePanelScaffold( - title: widget.isEditing ? 'Edit bank detail' : 'Add bank detail', + title: widget.isEditing ? 'Edit Bank Detail' : 'Add Bank Detail', footer: _panelFooter( context, isSubmitting: _isSubmitting, @@ -451,7 +451,7 @@ class _VendorBankDetailPanelState extends ConsumerState { AppTextField( controller: _bankNameController, label: 'Bank Name *', - validator: (v) => Validators.required(v, fieldName: 'Bank name'), + validator: (v) => Validators.required(v, fieldName: 'Bank Name'), ), const SizedBox(height: 12), AppTextField(controller: _branchController, label: 'Branch'), @@ -477,7 +477,7 @@ class _VendorBankDetailPanelState extends ConsumerState { controller: _holderNameController, label: 'Account Holder *', validator: (v) => - Validators.required(v, fieldName: 'Account holder name'), + Validators.required(v, fieldName: 'Account Holder Name'), ), ), const SizedBox(height: 12), @@ -492,7 +492,7 @@ class _VendorBankDetailPanelState extends ConsumerState { const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, - title: const Text('Primary account'), + title: const Text('Primary Account'), value: _isPrimary, onChanged: (v) => setState(() => _isPrimary = v), ), diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index fa665a2..9f977bd 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -12,6 +12,7 @@ class AppDataColumn { this.sortKey, this.flex = 1, this.alignment = Alignment.centerLeft, + this.padding = EdgeInsets.zero, }); final String label; @@ -19,6 +20,7 @@ class AppDataColumn { final String? sortKey; final int flex; final Alignment alignment; + final EdgeInsets padding; } /// Helpers for table cell content — single-line text with ellipsis and tooltip. @@ -99,6 +101,7 @@ class AppDataTable extends StatelessWidget { final body = ListView.builder( padding: EdgeInsets.zero, shrinkWrap: shrinkWrap, + clipBehavior: Clip.none, physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, itemCount: rows.length, itemBuilder: (context, index) => _TableDataRow( @@ -107,7 +110,8 @@ class AppDataTable extends StatelessWidget { ), ); - // Header stays fixed; only body rows scroll (when not shrink-wrapped). + // Header stays fixed; body scrolls in the remaining space and is clipped so + // rows cannot paint over footers (e.g. pagination) outside the table. final table = shrinkWrap ? Column( mainAxisSize: MainAxisSize.min, @@ -121,7 +125,9 @@ class AppDataTable extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ header, - Expanded(child: body), + Expanded( + child: ClipRect(child: body), + ), ], ); @@ -156,11 +162,7 @@ class _TableHeaderRow extends StatelessWidget { width: double.infinity, child: DecoratedBox( decoration: BoxDecoration( - // Opaque so scrolling rows never show through the sticky header. - color: Color.alphaBlend( - theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), - theme.colorScheme.surface, - ), + color: theme.colorScheme.surfaceContainerHighest, border: Border( bottom: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.12), @@ -211,9 +213,12 @@ class _TableHeaderRow extends StatelessWidget { return Expanded( flex: col.flex, - child: Align( - alignment: col.alignment, - child: header, + child: Padding( + padding: col.padding, + child: Align( + alignment: col.alignment, + child: header, + ), ), ); }).toList(), @@ -242,6 +247,7 @@ class _TableDataRow extends StatelessWidget { width: double.infinity, child: DecoratedBox( decoration: BoxDecoration( + color: theme.colorScheme.surface, border: Border( bottom: BorderSide( color: theme.colorScheme.outline.withValues(alpha: 0.08), @@ -255,11 +261,14 @@ class _TableDataRow extends StatelessWidget { children: columns.map((col) { return Expanded( flex: col.flex, - child: Align( - alignment: col.alignment, - child: _TableCellSlot( + child: Padding( + padding: col.padding, + child: Align( alignment: col.alignment, - child: col.cellBuilder(context, row), + child: _TableCellSlot( + alignment: col.alignment, + child: col.cellBuilder(context, row), + ), ), ), ); @@ -289,6 +298,7 @@ class _TableCellSlot extends StatelessWidget { child: Align( alignment: alignment, widthFactor: 1, + heightFactor: 1, child: _coerceTableCell(child, context), ), ); diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index 50f0b64..d04daf6 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -9,6 +9,8 @@ import '../../core/constants/enums.dart'; import '../../core/constants/route_constants.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/theme_provider.dart'; +import '../../core/theme/theme_provider.dart'; +import '../../core/utils/favicon_store.dart'; import '../../modules/settings/presentation/providers/settings_provider.dart'; import '../models/user_model.dart'; import '../providers/auth_provider.dart'; @@ -268,29 +270,37 @@ class _AppSidebarState extends ConsumerState { companyProfileLogo: companyProfile.logoUrl, brandingLogo: branding.logoUrl, ); - final title = resolveSidebarTitle( - companyName: companyProfile.companyName, - fallback: AppConstants.appName, + final faviconUrl = resolveSidebarFaviconUrl( + companyProfileFavicon: companyProfile.faviconUrl, + storedFavicon: FaviconStore(ref.watch(sharedPreferencesProvider)).read(), ); if (isNarrow) { return Padding( - padding: const EdgeInsets.fromLTRB(6, 16, 6, 0), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ - SidebarLogo(logoUrl: logoUrl, size: 32), + SidebarLogo( + logoUrl: faviconUrl, + size: 24, + fit: BoxFit.contain, + showBackground: false, + ), if (widget.onToggleCollapse != null) ...[ const SizedBox(height: 2), IconButton( padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor(width: 32, height: 32), + constraints: + const BoxConstraints.tightFor(width: 32, height: 32), visualDensity: VisualDensity.compact, icon: const Icon(Icons.chevron_right, size: 18), tooltip: 'Expand sidebar', onPressed: widget.onToggleCollapse, ), ], + const SizedBox(height: 12), + _LogoDivider(theme: theme), ], ), ); @@ -302,18 +312,19 @@ class _AppSidebarState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: SidebarLogo( logoUrl: logoUrl, - height: 44, + height: 40, width: double.infinity, fit: BoxFit.contain, showBackground: false, ), ), - if (widget.onToggleCollapse != null) + if (widget.onToggleCollapse != null) ...[ + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.chevron_left, size: 20), tooltip: 'Collapse sidebar', @@ -323,31 +334,11 @@ class _AppSidebarState extends ConsumerState { const BoxConstraints.tightFor(width: 36, height: 36), onPressed: widget.onToggleCollapse, ), + ], ], ), - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.24), - ), - ), - child: Text( - title, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: -0.2, - color: theme.colorScheme.primary, - ), - ), - ), + const SizedBox(height: 14), + _LogoDivider(theme: theme), ], ), ); @@ -737,6 +728,39 @@ class _SidebarFlyoutFadeState extends State<_SidebarFlyoutFade> } } +class _LogoDivider extends StatelessWidget { + const _LogoDivider({required this.theme}); + + final ThemeData theme; + + @override + Widget build(BuildContext context) { + final primary = theme.colorScheme.primary; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + height: 2, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: LinearGradient( + colors: [ + primary.withValues(alpha: 0), + primary.withValues(alpha: 0.55), + primary, + primary.withValues(alpha: 0.55), + primary.withValues(alpha: 0), + ], + stops: const [0, 0.2, 0.5, 0.8, 1], + ), + ), + ), + ), + ); + } +} + class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.label}); diff --git a/lib/shared/widgets/app_status_chip.dart b/lib/shared/widgets/app_status_chip.dart index e3fc061..05bb005 100644 --- a/lib/shared/widgets/app_status_chip.dart +++ b/lib/shared/widgets/app_status_chip.dart @@ -2,19 +2,86 @@ import 'package:flutter/material.dart'; import '../../core/constants/enums.dart'; +/// Uniform width for status chips inside data tables (fits "Partially Received"). +const double kTableStatusChipWidth = 132; + +/// Pill badge with optional fixed width for table status columns. +class TableStatusBadge extends StatelessWidget { + const TableStatusBadge({ + super.key, + required this.label, + required this.color, + this.compact = true, + this.fixedWidth, + }); + + final String label; + final Color color; + final bool compact; + + /// When set, every badge in a table column uses the same width. + final double? fixedWidth; + + @override + Widget build(BuildContext context) { + final badge = Container( + height: compact ? 22 : 26, + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.28)), + ), + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: color, + fontSize: compact ? 11 : 12, + fontWeight: FontWeight.w600, + height: 1, + letterSpacing: 0.1, + ), + ), + ), + ); + + if (fixedWidth == null) return badge; + + return SizedBox( + width: fixedWidth, + child: badge, + ); + } +} + class AppStatusChip extends StatelessWidget { const AppStatusChip({ super.key, required this.status, this.compact = false, + this.forTable = false, }); final String status; final bool compact; + final bool forTable; @override Widget build(BuildContext context) { final (color, label) = _resolveStatus(status); + if (forTable) { + return TableStatusBadge( + label: label, + color: color, + compact: compact, + fixedWidth: kTableStatusChipWidth, + ); + } + return Chip( label: Text( label, diff --git a/lib/shared/widgets/app_table_action_icon.dart b/lib/shared/widgets/app_table_action_icon.dart index ecdda88..92cd02c 100644 --- a/lib/shared/widgets/app_table_action_icon.dart +++ b/lib/shared/widgets/app_table_action_icon.dart @@ -1,5 +1,31 @@ import 'package:flutter/material.dart'; +class _TableActionInkWell extends StatelessWidget { + const _TableActionInkWell({ + required this.onTap, + required this.child, + }); + + final VoidCallback? onTap; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + hoverColor: theme.colorScheme.onSurface.withValues(alpha: 0.08), + splashColor: theme.colorScheme.onSurface.withValues(alpha: 0.12), + child: child, + ), + ); + } +} + /// Compact outline icon for data-table action columns (matches Users & Roles table). class AppTableActionIcon extends StatelessWidget { const AppTableActionIcon({ @@ -8,43 +34,136 @@ class AppTableActionIcon extends StatelessWidget { required this.icon, required this.onPressed, this.color, + this.enabled = true, }); final String tooltip; final IconData icon; final VoidCallback onPressed; final Color? color; + final bool enabled; @override Widget build(BuildContext context) { - final iconColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + final theme = Theme.of(context); + final iconColor = color ?? theme.colorScheme.onSurfaceVariant; + final effectiveColor = + enabled ? iconColor : iconColor.withValues(alpha: 0.38); return Tooltip( message: tooltip, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(6), + child: _TableActionInkWell( + onTap: enabled ? onPressed : null, child: Padding( padding: const EdgeInsets.all(6), - child: Icon(icon, size: 18, color: iconColor), + child: Icon(icon, size: 18, color: effectiveColor), ), ), ); } } -/// Right-aligned row of [AppTableActionIcon] widgets for table cells. -class AppTableActions extends StatelessWidget { - const AppTableActions({super.key, required this.children}); +/// Collapsed three-dot trigger that expands inline action icons on hover (or tap). +class AppTableActions extends StatefulWidget { + const AppTableActions({ + super.key, + required this.children, + this.expandHitArea = true, + }); final List children; + /// When true, hovering anywhere in the actions column cell reveals icons. + final bool expandHitArea; + + @override + State createState() => _AppTableActionsState(); +} + +/// Room for expanded icons to grow left of the ⋮ trigger without clipping. +const double _kExpandedActionsOverflow = 108; + +class _AppTableActionsState extends State { + bool _hovering = false; + bool _pinned = false; + + bool get _expanded => _hovering || _pinned; + @override Widget build(BuildContext context) { - return Row( + final children = widget.children; + if (children.isEmpty) return const SizedBox.shrink(); + + final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + + final actionRow = Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, - children: children, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.centerRight, + clipBehavior: Clip.none, + child: _expanded + ? Row( + mainAxisSize: MainAxisSize.min, + children: children, + ) + : const SizedBox.shrink(), + ), + Tooltip( + message: 'Actions', + child: _TableActionInkWell( + onTap: () => setState(() => _pinned = !_pinned), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.more_vert, size: 18, color: iconColor), + ), + ), + ), + ], + ); + + final interactive = TapRegion( + onTapOutside: (_) { + if (_pinned) setState(() => _pinned = false); + }, + child: MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: actionRow, + ), + ); + + if (!widget.expandHitArea) return interactive; + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + if (!width.isFinite || width <= 0) return interactive; + + return SizedBox( + width: width, + child: OverflowBox( + maxWidth: width + _kExpandedActionsOverflow, + alignment: Alignment.centerRight, + child: TapRegion( + onTapOutside: (_) { + if (_pinned) setState(() => _pinned = false); + }, + child: MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: Align( + alignment: Alignment.centerRight, + child: actionRow, + ), + ), + ), + ), + ); + }, ); } } diff --git a/lib/shared/widgets/app_table_shell.dart b/lib/shared/widgets/app_table_shell.dart index f09d5a1..5ae11b4 100644 --- a/lib/shared/widgets/app_table_shell.dart +++ b/lib/shared/widgets/app_table_shell.dart @@ -33,16 +33,21 @@ class AppTableShell extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: toolbar, ), const Divider(height: 1), - Expanded(child: child), + // Clip so scrolling rows cannot paint over the footer/pagination. + Expanded(child: ClipRect(child: child)), if (footer != null) ...[ const Divider(height: 1), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: footer!, + Material( + color: theme.colorScheme.surface, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: footer!, + ), ), ], ], diff --git a/lib/shared/widgets/app_text_field.dart b/lib/shared/widgets/app_text_field.dart index 15d8aeb..876273a 100644 --- a/lib/shared/widgets/app_text_field.dart +++ b/lib/shared/widgets/app_text_field.dart @@ -17,9 +17,11 @@ class AppTextField extends StatelessWidget { this.maxLength, this.inputFormatters, this.enabled = true, + this.readOnly = false, this.autofillHints, this.isDense = false, this.autovalidateMode, + this.fillColor, }); final TextEditingController controller; @@ -35,9 +37,11 @@ class AppTextField extends StatelessWidget { final int? maxLength; final List? inputFormatters; final bool enabled; + final bool readOnly; final Iterable? autofillHints; final bool isDense; final AutovalidateMode? autovalidateMode; + final Color? fillColor; @override Widget build(BuildContext context) { @@ -54,6 +58,7 @@ class AppTextField extends StatelessWidget { maxLength: maxLength, inputFormatters: inputFormatters, enabled: enabled, + readOnly: readOnly, autofillHints: autofillHints, decoration: InputDecoration( labelText: label, @@ -63,6 +68,9 @@ class AppTextField extends StatelessWidget { isDense: isDense, floatingLabelBehavior: label != null ? FloatingLabelBehavior.always : null, + ).copyWith( + filled: fillColor != null ? true : null, + fillColor: fillColor, ), ), ); diff --git a/lib/shared/widgets/master_inline_quick_add_form.dart b/lib/shared/widgets/master_inline_quick_add_form.dart index e5ee054..6755cb2 100644 --- a/lib/shared/widgets/master_inline_quick_add_form.dart +++ b/lib/shared/widgets/master_inline_quick_add_form.dart @@ -271,7 +271,7 @@ class _MasterInlineQuickAddFormState const SizedBox(width: 6), Expanded( child: Text( - 'Add ${_definition.title.toLowerCase()}', + 'Add ${_definition.title}', style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), @@ -330,7 +330,7 @@ class _MasterInlineQuickAddFormState color: Colors.white, ), ) - : const Text('Save'), + : Text('Add ${_definition.title}'), ), ], ), diff --git a/lib/shared/widgets/page_header.dart b/lib/shared/widgets/page_header.dart index 31ecde6..79766de 100644 --- a/lib/shared/widgets/page_header.dart +++ b/lib/shared/widgets/page_header.dart @@ -19,7 +19,7 @@ class PageHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.only(bottom: 12), child: context.isMobile ? Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/shared/widgets/sidebar_logo.dart b/lib/shared/widgets/sidebar_logo.dart index fd57de9..0fdb7b5 100644 --- a/lib/shared/widgets/sidebar_logo.dart +++ b/lib/shared/widgets/sidebar_logo.dart @@ -106,6 +106,16 @@ String? resolveSidebarLogoUrl({ return resolveMediaUrl(brandingLogo); } +/// Resolves favicon URL from company profile settings or local cache. +String? resolveSidebarFaviconUrl({ + required String companyProfileFavicon, + String? storedFavicon, +}) { + final fromProfile = resolveMediaUrl(companyProfileFavicon); + if (fromProfile != null && fromProfile.isNotEmpty) return fromProfile; + return resolveMediaUrl(storedFavicon); +} + /// Resolves sidebar title from company name or app tagline. String resolveSidebarTitle({ required String companyName, diff --git a/web/index.html b/web/index.html index f9f01f0..1632ad0 100644 --- a/web/index.html +++ b/web/index.html @@ -43,9 +43,69 @@ - + + + bharat_erp