This commit is contained in:
Surendiran 2026-07-14 16:02:04 +05:30
parent ce0979d782
commit c20b12c261
63 changed files with 1238 additions and 734 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,6 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
),
],
),
const SizedBox(height: 16),
TabBar(
controller: _tabController,
tabs: const [
@ -72,7 +71,7 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
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<int>(
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(

View File

@ -72,15 +72,15 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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 <AssetDropdownOption>[]))
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<AssetListScreen> {
),
],
),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(assetsListProvider.notifier).refresh(),
@ -154,19 +153,19 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
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<AssetListScreen> {
names.add(category.name);
}
final sorted = names.toList()..sort();
return ['All categories', ...sorted];
return ['All Categories', ...sorted];
}
List<String> _plantOptions(List<AssetModel> assets) {
@ -271,7 +270,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
.toSet()
.toList()
..sort();
return ['All plants', ...names];
return ['All Plants', ...names];
}
List<String> _statusOptions(List<AssetDropdownOption> apiStatuses) {
@ -279,7 +278,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
.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(

View File

@ -484,7 +484,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
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<AssetFormPanel> {
),
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<AssetFormPanel> {
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<AssetFormPanel> {
return Validators.minLength(
v.trim(),
2,
fieldName: 'Serial number',
fieldName: 'Serial Number',
);
},
),
@ -620,7 +620,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength(
v.trim(),
2,
fieldName: 'Part number',
fieldName: 'Part Number',
);
},
),
@ -636,7 +636,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength(
v.trim(),
2,
fieldName: 'Brand / model',
fieldName: 'Brand / Model',
);
},
),
@ -697,7 +697,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength(
v.trim(),
2,
fieldName: 'Location detail',
fieldName: 'Location Detail',
);
},
),
@ -792,7 +792,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
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<AssetFormPanel> {
keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt(
v,
fieldName: 'Useful life',
fieldName: 'Useful Life',
),
),
),
@ -829,7 +829,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
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<AssetFormPanel> {
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<AssetFormPanel> {
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<AssetFormPanel> {
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<AssetFormPanel> {
return Validators.minLength(
v.trim(),
2,
fieldName: 'QR code value',
fieldName: 'QR Code Value',
);
},
),

View File

@ -317,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
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<AddAmcPanel> {
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<AddAmcPanel> {
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Update contract',
saveLabel: 'Edit AMC Contract',
onSave: _save,
),
child: _buildForm(),
@ -416,7 +416,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save contract',
saveLabel: 'Add AMC Contract',
onSave: _save,
),
child: _buildForm(),
@ -670,7 +670,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt(
v,
fieldName: 'Visit number',
fieldName: 'Visit Number',
),
),
),
@ -783,7 +783,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
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<LogServiceVisitPanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
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<AddInsurancePanel> {
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Update policy',
saveLabel: 'Edit Insurance Policy',
onSave: _save,
),
child: _buildForm(),
@ -1225,7 +1225,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save policy',
saveLabel: 'Add Insurance Policy',
onSave: _save,
),
child: _buildForm(),

View File

@ -148,7 +148,6 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
),
],
),
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<int?>(
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),
),

View File

@ -42,7 +42,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
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(

View File

@ -51,7 +51,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
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),

View File

@ -47,13 +47,13 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
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'),

View File

@ -51,13 +51,13 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
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(

View File

@ -48,7 +48,6 @@ class _CompanyListScreenState extends ConsumerState<CompanyListScreen> {
),
],
),
const SizedBox(height: 16),
Expanded(
child: profile.companyName.isEmpty
? const AppEmptyState(

View File

@ -30,6 +30,7 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false;
bool _isDownloadingPdf = false;
@override
Widget build(BuildContext context) {
@ -60,6 +61,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
_DetailHeader(
grn: grn,
isWorking: _isWorking,
isDownloadingPdf: _isDownloadingPdf,
canEdit: canEdit,
canExport: canExport,
onBack: () => context.go(RouteConstants.grn),
@ -141,7 +143,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
),
const SizedBox(height: 16),
AppTextField(
label: 'Cancellation reason *',
label: 'Cancellation Reason *',
controller: reasonController,
maxLines: 3,
),
@ -174,7 +176,8 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
}
Future<void> _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<GrnDetailScreen> {
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),
),
];

View File

@ -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<GrnFormScreen> {
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<GrnFormScreen> {
),
if (!widget.isEditing)
AppSearchableDropdown<String>(
label: 'Purchase order *',
label: 'Purchase Order *',
value: _selectedPoId,
hint: 'Select PO',
searchHint: 'Search PO...',
@ -447,12 +449,12 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
}
},
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<int>(
@ -475,7 +477,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
enabled: !widget.isEditing,
),
AppTextField(
label: 'Vendor invoice no',
label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController,
),
],
@ -483,7 +485,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
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<GrnFormScreen> {
),
),
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<GrnFormScreen> {
FormRowFour(
children: [
_DateField(
label: 'LR date',
label: 'LR Date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
@ -519,7 +530,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
),
),
AppSearchableDropdown<int>(
label: 'Received by',
label: 'Received By',
value: _dropdownValue(
_normalizeUserId(_receivedById),
userIds,
@ -531,7 +542,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality checked by',
label: 'Quality Checked By',
value: _dropdownValue(
_normalizeUserId(_qualityCheckedById),
userIds,
@ -616,7 +627,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
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.';

View File

@ -71,7 +71,6 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
),
],
),
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',

View File

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

View File

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

View File

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

View File

@ -334,9 +334,28 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final widgets = <Widget>[];
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<MasterFormPanel> {
right: _buildField(context, field: right, formState: formState),
),
);
i += 2;
} else {
widgets.add(
Padding(
@ -354,6 +374,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
),
),
);
i += 1;
}
}
@ -378,7 +399,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
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<MasterFormPanel> {
),
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,

View File

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

View File

@ -34,6 +34,7 @@ class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
class _PurchaseOrderDetailScreenState
extends ConsumerState<PurchaseOrderDetailScreen> {
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<void> _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),

View File

@ -387,7 +387,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
FormRowFour(
children: [
_DateField(
label: 'PO date *',
label: 'PO Date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
@ -395,7 +395,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
),
),
AppSearchableDropdown<String>(
label: 'PO type *',
label: 'PO Type *',
value: _poType,
hint: 'Select PO type',
searchHint: 'Search type...',
@ -466,7 +466,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
),
MasterQuickAddDropdown<int?>(
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<PurchaseOrderFormScree
),
MasterQuickAddDropdown<int?>(
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<PurchaseOrderFormScree
columnCount: 4,
children: [
_DateField(
label: 'Expected delivery',
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
@ -536,7 +536,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
children: [
AppTextField(
controller: _termsController,
label: 'Terms & conditions',
label: 'Terms & Conditions',
hint: 'Payment terms, inspection conditions, etc.',
maxLines: 5,
),
@ -609,8 +609,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
Widget _buildHeader(PurchaseOrderModel? existing) {
final theme = Theme.of(context);
final title = widget.isEditing
? 'Edit ${existing?.poNo ?? 'purchase order'}'
: 'Create purchase order';
? 'Edit ${existing?.poNo ?? 'Purchase Order'}'
: 'Create Purchase Order';
final subtitle = widget.isEditing
? null
: 'Fill in order details, add line items, then review the totals before saving.';
@ -625,8 +625,8 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
const SizedBox(width: 12),
AppButton(
label: widget.isEditing
? 'Update purchase order'
: 'Save purchase order',
? 'Update Purchase Order'
: 'Save Purchase Order',
icon: Icons.check,
expand: false,
isLoading: _isSubmitting,
@ -839,7 +839,7 @@ class _AmountSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SummaryReadOnlyRow(
label: 'Taxable amount',
label: 'Taxable Amount',
value: CurrencyFormatter.format(totals.taxableAmount),
),
const SizedBox(height: 10),
@ -849,15 +849,15 @@ class _AmountSummaryCard extends StatelessWidget {
),
const SizedBox(height: 4),
_SummaryInputRow(
label: 'Freight charges',
label: 'Freight Charges',
controller: freightController,
),
_SummaryInputRow(
label: 'Other charges',
label: 'Other Charges',
controller: otherChargesController,
),
_SummaryInputRow(
label: 'Discount amount',
label: 'Discount Amount',
controller: discountController,
valueColor: discountInvalid || discountValue > 0
? AppColors.error

View File

@ -98,7 +98,6 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
),
],
),
const SizedBox(height: 16),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
@ -220,7 +219,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
options.sort((a, b) => a.label.compareTo(b.label));
return [
const AppDropdownOption<String?>(value: null, label: 'All statuses'),
const AppDropdownOption<String?>(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',

View File

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

View File

@ -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<AppDropdownOption<int>>()
.toList();
final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'Select GST rate'),
const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
...widget.gstRates.map((e) {
final id = _parseId(e.id);
if (id == null) return null;

View File

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

View File

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

View File

@ -228,7 +228,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
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<AddUserPanel> {
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<AddUserPanel> {
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<AddUserPanel> {
AppTextField(
controller: _passwordController,
label: widget.isEditing
? 'New password'
: 'Temporary password *',
? 'New Password'
: 'Temporary Password *',
hint: 'Min. 8 characters',
obscureText: true,
validator: widget.isEditing

View File

@ -98,7 +98,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
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<RoleFormPanel> {
children: [
AppTextField(
controller: _nameController,
label: 'Role name *',
label: 'Role Name *',
hint: 'e.g. QC Manager',
validator: Validators.roleName,
inputFormatters: Validators.roleNameInput,

View File

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

View File

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

View File

@ -64,7 +64,6 @@ class _PermissionMatrixScreenState extends ConsumerState<PermissionMatrixScreen>
),
],
),
const SizedBox(height: 16),
Expanded(
child: context.isMobile
? _MatrixCardList(roleId: widget.roleId, matrix: matrix)

View File

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

View File

@ -17,9 +17,12 @@ class SettingsRemoteDataSource {
if (data is Map) return Map<String, dynamic>.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<String?> uploadCompanyFavicon(List<int> 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<String, dynamic>.from(responseData);
String? fromMap(Map<String, dynamic> 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<String, dynamic>.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<EmailConfigurationSettings?> fetchEmail() async {
final response = await _dio.get(ApiEndpoints.settingsEmail);

View File

@ -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<String?>(() => remote.uploadCompanyLogo(bytes, filename));
}
@override
Future<Result<String?>> uploadCompanyFavicon(
List<int> bytes,
String filename,
) async {
return safeApiCall<String?>(
() => remote.uploadCompanyFavicon(bytes, filename),
);
}
@override
Future<Result<EmailConfigurationSettings>> fetchEmailSettings() async {
return safeApiCall(() async {

View File

@ -150,6 +150,7 @@ class CompanyProfileSettings {
/// Payload for `PUT /settings/company` ([CompanySettingsBody]).
Map<String, dynamic> 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?,
) ??
'',
);
}

View File

@ -12,6 +12,10 @@ abstract class SettingsRepository {
List<int> bytes,
String filename,
);
Future<Result<String?>> uploadCompanyFavicon(
List<int> bytes,
String filename,
);
Future<Result<EmailConfigurationSettings>> fetchEmailSettings();
Future<Result<EmailConfigurationSettings>> saveEmailSettings(
EmailConfigurationSettings email,

View File

@ -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<AppSettings> {
);
}
Future<void> _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<void> _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<AppSettings> {
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<AppSettings> {
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<AppSettings> {
return result;
}
/// POST `/settings/company/favicon` also updates the browser tab icon.
Future<Result<String?>> uploadCompanyFavicon(
List<int> 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<void> applyLocalCompanyLogo(String logoUrl) async {
final resolved = resolveMediaUrl(logoUrl) ?? logoUrl;
@ -176,6 +207,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await _syncMainLogo(profile);
}
/// Applies a local/preview favicon when upload is unavailable.
Future<void> 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<void> updateUiPreferences(UiPreferencesSettings prefs) async {
await _persist(state.copyWith(uiPreferences: prefs));
}

View File

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

View File

@ -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<void> _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<void> _pickLogo() async {
final result = await FilePicker.pickFiles(
type: FileType.image,
@ -256,9 +231,68 @@ class _CompanyProfileSettingsScreenState
setState(() => _logoUrlController.text = dataUri);
}
Future<void> _pickFavicon() => _pickImage((dataUri) {
_faviconUrlController.text = dataUri;
});
Future<void> _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',
),
),
],
),

View File

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

View File

@ -140,7 +140,7 @@ class _SecuritySettingsScreenState extends ConsumerState<SecuritySettingsScreen>
children: [
AppTextField(
controller: _sessionTimeoutController,
label: 'Session Timeout (minutes)',
label: 'Session Timeout (Minutes)',
keyboardType: TextInputType.number,
),
SettingsSwitchTile(
@ -167,7 +167,7 @@ class _SecuritySettingsScreenState extends ConsumerState<SecuritySettingsScreen>
const SizedBox(height: 16),
AppTextField(
controller: _lockDurationController,
label: 'Account Lock Duration (minutes)',
label: 'Account Lock Duration (Minutes)',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),

View File

@ -175,13 +175,13 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
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(

View File

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

View File

@ -245,7 +245,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
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(

View File

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

View File

@ -241,7 +241,7 @@ class _OverviewTab extends StatelessWidget {
gstTreatmentLabel(vendor.gstTreatment),
),
_VendorInfo(
'Source of Supply',
'Source Of Supply',
vendor.sourceOfSupply ?? '',
),
_VendorInfo('GSTIN', vendor.gstin ?? ''),

View File

@ -85,7 +85,6 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
),
],
),
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(

View File

@ -186,7 +186,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
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<VendorFormPanel> {
),
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<VendorFormPanel> {
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<String>(
@ -278,11 +278,11 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
loading: () => const LinearProgressIndicator(),
error: (_, __) => AppTextField(
controller: TextEditingController(text: _sourceOfSupply ?? ''),
label: 'Source of Supply',
label: 'Source Of Supply',
onChanged: (v) => _sourceOfSupply = v,
),
data: (options) => AppSearchableDropdown<String>(
label: 'Source of Supply',
label: 'Source Of Supply',
value: _sourceOfSupply,
searchHint: 'Search state...',
options: options
@ -316,7 +316,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
),
right: AppTextField(
controller: _creditDaysController,
label: 'Credit Period (days)',
label: 'Credit Period (Days)',
keyboardType: TextInputType.number,
),
),

View File

@ -147,7 +147,7 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
@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<VendorContactPanel> {
@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<VendorContactPanel> {
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<VendorContactPanel> {
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<VendorBankDetailPanel> {
@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<VendorBankDetailPanel> {
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<VendorBankDetailPanel> {
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<VendorBankDetailPanel> {
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),
),

View File

@ -12,6 +12,7 @@ class AppDataColumn<T> {
this.sortKey,
this.flex = 1,
this.alignment = Alignment.centerLeft,
this.padding = EdgeInsets.zero,
});
final String label;
@ -19,6 +20,7 @@ class AppDataColumn<T> {
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<T> 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<T>(
@ -107,7 +110,8 @@ class AppDataTable<T> 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<T> extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
header,
Expanded(child: body),
Expanded(
child: ClipRect(child: body),
),
],
);
@ -156,11 +162,7 @@ class _TableHeaderRow<T> 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<T> 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<T> 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<T> 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),
),
);

View File

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

View File

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

View File

@ -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<Widget> children;
/// When true, hovering anywhere in the actions column cell reveals icons.
final bool expandHitArea;
@override
State<AppTableActions> createState() => _AppTableActionsState();
}
/// Room for expanded icons to grow left of the trigger without clipping.
const double _kExpandedActionsOverflow = 108;
class _AppTableActionsState extends State<AppTableActions> {
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,
),
),
),
),
);
},
);
}
}

View File

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

View File

@ -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<TextInputFormatter>? inputFormatters;
final bool enabled;
final bool readOnly;
final Iterable<String>? 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,
),
),
);

View File

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

View File

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

View File

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

View File

@ -43,9 +43,69 @@
<meta name="apple-mobile-web-app-title" content="bharat_erp">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<!-- Favicon (updated at runtime via setAppFavicon) -->
<link rel="icon" type="image/png" href="favicon.png"/>
<script>
window.setAppFavicon = function (url) {
if (!url) return;
var busted =
url +
(url.indexOf('?') >= 0 ? '&' : '?') +
'_fc=' +
Date.now();
document
.querySelectorAll(
'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
)
.forEach(function (el) {
el.parentNode.removeChild(el);
});
function applyHref(href, type) {
var link = document.createElement('link');
link.rel = 'icon';
if (type) link.type = type;
link.href = href;
document.head.appendChild(link);
var shortcut = document.createElement('link');
shortcut.rel = 'shortcut icon';
if (type) shortcut.type = type;
shortcut.href = href;
document.head.appendChild(shortcut);
}
// Data URLs apply instantly in the tab.
if (url.indexOf('data:image/') === 0) {
var dataType = url.substring(5, url.indexOf(';'));
applyHref(url, dataType || 'image/png');
return;
}
var img = new Image();
img.onload = function () {
try {
var canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
var ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, 32, 32);
applyHref(canvas.toDataURL('image/png'), 'image/png');
} catch (e) {
applyHref(busted, null);
}
};
img.onerror = function () {
applyHref(busted, null);
};
img.crossOrigin = 'anonymous';
img.src = busted;
};
</script>
<title>bharat_erp</title>
<link rel="manifest" href="manifest.json">
</head>