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 settingsGeneral = '/settings/general';
static const String settingsCompany = '/settings/company'; static const String settingsCompany = '/settings/company';
static String settingsCompanyLogo = '/settings/company/logo'; static String settingsCompanyLogo = '/settings/company/logo';
static const String settingsCompanyFavicon = '/settings/company/favicon';
static const String settingsAsset = '/settings/asset'; static const String settingsAsset = '/settings/asset';
static const String settingsNotifications = '/settings/notifications'; static const String settingsNotifications = '/settings/notifications';
static const String settingsEmail = '/settings/email'; static const String settingsEmail = '/settings/email';

View File

@ -14,10 +14,9 @@ class FaviconStore {
final value = url?.trim() ?? ''; final value = url?.trim() ?? '';
if (value.isEmpty) { if (value.isEmpty) {
await _prefs.remove(StorageKeys.faviconUrl); await _prefs.remove(StorageKeys.faviconUrl);
return; } else {
await _prefs.setString(StorageKeys.faviconUrl, value);
} }
await _prefs.setString(StorageKeys.faviconUrl, value);
} }
void apply() { void apply() {

View File

@ -1,4 +1,4 @@
import 'favicon_updater_stub.dart' 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); 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) { void updateFavicon(String? url) {
for (final link in html.document.querySelectorAll('link[rel="icon"]')) { final trimmed = url?.trim();
link.remove(); if (trimmed == null || trimmed.isEmpty) return;
} _setAppFavicon(trimmed.toJS);
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;
} }

View File

@ -412,7 +412,7 @@ class Validators {
/// Required role name letters, numbers, and spaces only. /// Required role name letters, numbers, and spaces only.
static String? roleName(String? value) { static String? roleName(String? value) {
final requiredError = required(value, fieldName: 'Role name'); final requiredError = required(value, fieldName: 'Role Name');
if (requiredError != null) return requiredError; if (requiredError != null) return requiredError;
final trimmed = value!.trim(); final trimmed = value!.trim();

View File

@ -64,7 +64,6 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
), ),
], ],
), ),
const SizedBox(height: 16),
TabBar( TabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
@ -72,7 +71,7 @@ class _AssetAlertsScreenState extends ConsumerState<AssetAlertsScreen>
Tab(text: 'Service Alerts'), Tab(text: 'Service Alerts'),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@ -111,11 +110,11 @@ class _ExpiryAlertsTab extends ConsumerWidget {
SizedBox( SizedBox(
width: 160, width: 160,
child: AppDropdown<int>( child: AppDropdown<int>(
label: 'Days ahead', label: 'Days Ahead',
isDense: true, isDense: true,
value: state.expiryDays, value: state.expiryDays,
options: const [7, 15, 30, 60, 90] options: const [7, 15, 30, 60, 90]
.map((d) => AppDropdownOption(value: d, label: '$d days')) .map((d) => AppDropdownOption(value: d, label: '$d Days'))
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
if (v != null) notifier.setExpiryDays(v); if (v != null) notifier.setExpiryDays(v);
@ -150,7 +149,7 @@ class _ExpiryAlertsTab extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_AlertsOverview( _AlertsOverview(
total: state.expiryAlerts.length, total: state.expiryAlerts.length,
label: 'Expiry alerts in selected window', label: 'Expiry Alerts In Selected Window',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(
@ -237,7 +236,7 @@ class _ServiceAlertsTab extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_AlertsOverview( _AlertsOverview(
total: state.serviceAlerts.length, total: state.serviceAlerts.length,
label: 'Service reminders', label: 'Service Reminders',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(

View File

@ -72,15 +72,15 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
final statuses = _statusOptions( final statuses = _statusOptions(
ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [], ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? const [],
); );
final categoryFilter = _selectedCategory ?? 'All categories'; final categoryFilter = _selectedCategory ?? 'All Categories';
final plantFilter = _selectedPlant ?? 'All plants'; final plantFilter = _selectedPlant ?? 'All Plants';
final statusFilter = _selectedStatus ?? 'All statuses'; final statusFilter = _selectedStatus ?? 'All Statuses';
final statusLabels = { final statusLabels = {
for (final option for (final option
in (ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ?? in (ref.watch(assetFormLookupsProvider).valueOrNull?.statuses ??
const <AssetDropdownOption>[])) const <AssetDropdownOption>[]))
option.value: option.label, option.value: option.label,
'All statuses': 'All statuses', 'All Statuses': 'All Statuses',
}; };
final page = state.query.page; final page = state.query.page;
final pageSize = state.query.limit; final pageSize = state.query.limit;
@ -110,7 +110,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => ref.read(assetsListProvider.notifier).refresh(), onRefresh: () => ref.read(assetsListProvider.notifier).refresh(),
@ -154,19 +153,19 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
onCategoryChanged: (value) { onCategoryChanged: (value) {
setState(() { setState(() {
_selectedCategory = _selectedCategory =
value == 'All categories' ? null : value; value == 'All Categories' ? null : value;
}); });
}, },
onPlantChanged: (value) { onPlantChanged: (value) {
setState(() { setState(() {
_selectedPlant = _selectedPlant =
value == 'All plants' ? null : value; value == 'All Plants' ? null : value;
}); });
}, },
onStatusChanged: (value) { onStatusChanged: (value) {
setState(() { setState(() {
_selectedStatus = _selectedStatus =
value == 'All statuses' ? null : value; value == 'All Statuses' ? null : value;
}); });
}, },
); );
@ -260,7 +259,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
names.add(category.name); names.add(category.name);
} }
final sorted = names.toList()..sort(); final sorted = names.toList()..sort();
return ['All categories', ...sorted]; return ['All Categories', ...sorted];
} }
List<String> _plantOptions(List<AssetModel> assets) { List<String> _plantOptions(List<AssetModel> assets) {
@ -271,7 +270,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
.toSet() .toSet()
.toList() .toList()
..sort(); ..sort();
return ['All plants', ...names]; return ['All Plants', ...names];
} }
List<String> _statusOptions(List<AssetDropdownOption> apiStatuses) { List<String> _statusOptions(List<AssetDropdownOption> apiStatuses) {
@ -279,7 +278,7 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
.map((option) => option.value) .map((option) => option.value)
.where((value) => value.trim().isNotEmpty) .where((value) => value.trim().isNotEmpty)
.toList(); .toList();
return ['All statuses', ...labels]; return ['All Statuses', ...labels];
} }
void _viewAsset(AssetModel asset) { void _viewAsset(AssetModel asset) {
@ -511,6 +510,8 @@ class _AssetDataTable extends StatelessWidget {
flex: 1, flex: 1,
cellBuilder: (_, asset) => AppStatusChip( cellBuilder: (_, asset) => AppStatusChip(
status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active', status: asset.status?.toLowerCase().replaceAll(' ', '_') ?? 'active',
compact: true,
forTable: true,
), ),
), ),
AppDataColumn( AppDataColumn(

View File

@ -484,7 +484,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null; widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null;
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit asset' : 'Add asset', title: widget.isEditing ? 'Edit Asset' : 'Add Asset',
footer: Row( footer: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -496,7 +496,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppButton( AppButton(
label: widget.isEditing ? 'Update asset' : 'Save asset', label: widget.isEditing ? 'Edit Asset' : 'Add Asset',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: _isSubmitting, isLoading: _isSubmitting,
@ -567,10 +567,10 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
label: 'Asset Name *', label: 'Asset Name *',
validator: (v) { validator: (v) {
final requiredError = final requiredError =
Validators.required(v, fieldName: 'Asset name'); Validators.required(v, fieldName: 'Asset Name');
if (requiredError != null) return requiredError; if (requiredError != null) return requiredError;
return Validators.minLength(v!.trim(), 2, return Validators.minLength(v!.trim(), 2,
fieldName: 'Asset name'); fieldName: 'Asset Name');
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -607,7 +607,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength( return Validators.minLength(
v.trim(), v.trim(),
2, 2,
fieldName: 'Serial number', fieldName: 'Serial Number',
); );
}, },
), ),
@ -620,7 +620,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength( return Validators.minLength(
v.trim(), v.trim(),
2, 2,
fieldName: 'Part number', fieldName: 'Part Number',
); );
}, },
), ),
@ -636,7 +636,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength( return Validators.minLength(
v.trim(), v.trim(),
2, 2,
fieldName: 'Brand / model', fieldName: 'Brand / Model',
); );
}, },
), ),
@ -697,7 +697,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength( return Validators.minLength(
v.trim(), v.trim(),
2, 2,
fieldName: 'Location detail', fieldName: 'Location Detail',
); );
}, },
), ),
@ -792,7 +792,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPositiveDouble( validator: (v) => Validators.optionalPositiveDouble(
v, v,
fieldName: 'Purchase cost', fieldName: 'Purchase Cost',
), ),
), ),
right: AppTextField( right: AppTextField(
@ -802,7 +802,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt( validator: (v) => Validators.optionalPositiveInt(
v, v,
fieldName: 'Useful life', fieldName: 'Useful Life',
), ),
), ),
), ),
@ -829,7 +829,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPercentage( validator: (v) => Validators.optionalPercentage(
v, v,
fieldName: 'Depreciation rate', fieldName: 'Depreciation Rate',
), ),
), ),
), ),
@ -842,7 +842,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalNonNegativeDouble( validator: (v) => Validators.optionalNonNegativeDouble(
v, v,
fieldName: 'Salvage value', fieldName: 'Salvage Value',
), ),
), ),
right: const SizedBox.shrink(), right: const SizedBox.shrink(),
@ -913,7 +913,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalNonNegativeDouble( validator: (v) => Validators.optionalNonNegativeDouble(
v, v,
fieldName: 'Disposal value', fieldName: 'Disposal Value',
), ),
), ),
), ),
@ -927,13 +927,13 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
final isDisposed = final isDisposed =
_status == 'DISPOSED' || _status == 'SCRAPPED'; _status == 'DISPOSED' || _status == 'SCRAPPED';
if (isDisposed) { if (isDisposed) {
return Validators.required(v, fieldName: 'Disposal reason'); return Validators.required(v, fieldName: 'Disposal Reason');
} }
if (v == null || v.trim().isEmpty) return null; if (v == null || v.trim().isEmpty) return null;
return Validators.minLength( return Validators.minLength(
v.trim(), v.trim(),
3, 3,
fieldName: 'Disposal reason', fieldName: 'Disposal Reason',
); );
}, },
), ),
@ -951,7 +951,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
return Validators.minLength( return Validators.minLength(
v.trim(), v.trim(),
2, 2,
fieldName: 'QR code value', fieldName: 'QR Code Value',
); );
}, },
), ),

View File

@ -317,7 +317,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt( validator: (v) => Validators.optionalPositiveInt(
v, v,
fieldName: 'Visits per year', fieldName: 'Visits Per Year',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -386,7 +386,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: true, isSubmitting: true,
saveLabel: 'Update contract', saveLabel: 'Edit AMC Contract',
onSave: () {}, onSave: () {},
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -402,7 +402,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Update contract', saveLabel: 'Edit AMC Contract',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -416,7 +416,7 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Save contract', saveLabel: 'Add AMC Contract',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -670,7 +670,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) => Validators.optionalPositiveInt( validator: (v) => Validators.optionalPositiveInt(
v, v,
fieldName: 'Visit number', fieldName: 'Visit Number',
), ),
), ),
), ),
@ -783,7 +783,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPositiveDouble( validator: (v) => Validators.optionalPositiveDouble(
v, v,
fieldName: 'Downtime hours', fieldName: 'Downtime Hours',
), ),
), ),
), ),
@ -794,7 +794,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalPositiveDouble( validator: (v) => Validators.optionalPositiveDouble(
v, v,
fieldName: 'Service cost', fieldName: 'Service Cost',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -830,7 +830,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: true, isSubmitting: true,
saveLabel: 'Update visit', saveLabel: 'Edit Service Visit',
onSave: () {}, onSave: () {},
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -846,7 +846,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Update visit', saveLabel: 'Edit Service Visit',
onSave: _save, onSave: _save,
), ),
child: _buildForm(widget.amcContracts), child: _buildForm(widget.amcContracts),
@ -860,7 +860,7 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Save visit', saveLabel: 'Log Service Visit',
onSave: _save, onSave: _save,
), ),
child: _buildForm(widget.amcContracts), child: _buildForm(widget.amcContracts),
@ -1046,12 +1046,12 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
left: AppTextField( left: AppTextField(
controller: _policyNoController, controller: _policyNoController,
label: 'Policy No *', label: 'Policy No *',
validator: (v) => Validators.required(v, fieldName: 'Policy no'), validator: (v) => Validators.required(v, fieldName: 'Policy No'),
), ),
right: AppTextField( right: AppTextField(
controller: _insurerNameController, controller: _insurerNameController,
label: 'Insurer Name *', label: 'Insurer Name *',
validator: (v) => Validators.required(v, fieldName: 'Insurer name'), validator: (v) => Validators.required(v, fieldName: 'Insurer Name'),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1096,7 +1096,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalNonNegativeDouble( validator: (v) => Validators.optionalNonNegativeDouble(
v, v,
fieldName: 'Sum insured', fieldName: 'Sum Insured',
), ),
), ),
), ),
@ -1107,7 +1107,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) => Validators.optionalNonNegativeDouble( validator: (v) => Validators.optionalNonNegativeDouble(
v, v,
fieldName: 'Annual premium', fieldName: 'Annual Premium',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1195,7 +1195,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: true, isSubmitting: true,
saveLabel: 'Update policy', saveLabel: 'Edit Insurance Policy',
onSave: () {}, onSave: () {},
), ),
child: const Center(child: CircularProgressIndicator()), child: const Center(child: CircularProgressIndicator()),
@ -1211,7 +1211,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Update policy', saveLabel: 'Edit Insurance Policy',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),
@ -1225,7 +1225,7 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
saveLabel: 'Save policy', saveLabel: 'Add Insurance Policy',
onSave: _save, onSave: _save,
), ),
child: _buildForm(), child: _buildForm(),

View File

@ -148,7 +148,6 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: LayoutBuilder(
@ -296,7 +295,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search table...', searchHint: 'Search table...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All tables'), const AppDropdownOption(value: null, label: 'All Tables'),
...filters.tableNames.map( ...filters.tableNames.map(
(name) => AppDropdownOption(value: name, label: name), (name) => AppDropdownOption(value: name, label: name),
), ),
@ -310,7 +309,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search action...', searchHint: 'Search action...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All actions'), const AppDropdownOption(value: null, label: 'All Actions'),
...filters.actions.map( ...filters.actions.map(
(action) => AppDropdownOption(value: action, label: action), (action) => AppDropdownOption(value: action, label: action),
), ),
@ -319,12 +318,12 @@ class _FiltersBar extends StatelessWidget {
); );
final performerDropdown = AppSearchableDropdown<int?>( final performerDropdown = AppSearchableDropdown<int?>(
label: 'Performed by', label: 'Performed By',
value: query.performedBy, value: query.performedBy,
searchHint: 'Search user...', searchHint: 'Search user...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All users'), const AppDropdownOption(value: null, label: 'All Users'),
...filters.performers.map( ...filters.performers.map(
(user) => AppDropdownOption( (user) => AppDropdownOption(
value: int.tryParse(user.id), value: int.tryParse(user.id),
@ -336,7 +335,7 @@ class _FiltersBar extends StatelessWidget {
); );
final dateField = AppFilterDateField( final dateField = AppFilterDateField(
label: 'Date range', label: 'Date Range',
value: _dateValue, value: _dateValue,
placeholder: 'Select range', placeholder: 'Select range',
icon: Icons.date_range_outlined, icon: Icons.date_range_outlined,
@ -427,7 +426,11 @@ class _AuditDataTable extends StatelessWidget {
label: 'Action', label: 'Action',
flex: 1, flex: 1,
cellBuilder: (_, row) => AppTableCell.child( cellBuilder: (_, row) => AppTableCell.child(
AppStatusChip(status: row.action, compact: true), AppStatusChip(
status: row.action,
compact: true,
forTable: true,
),
), ),
), ),
AppDataColumn( AppDataColumn(
@ -441,7 +444,7 @@ class _AuditDataTable extends StatelessWidget {
cellBuilder: (_, row) => AppTableCell.text(row.recordId), cellBuilder: (_, row) => AppTableCell.text(row.recordId),
), ),
AppDataColumn( AppDataColumn(
label: 'Performed by', label: 'Performed By',
flex: 2, flex: 2,
cellBuilder: (_, row) => AppTableCell.text(row.performerLabel), cellBuilder: (_, row) => AppTableCell.text(row.performerLabel),
), ),

View File

@ -42,7 +42,7 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
controller: _currentController, controller: _currentController,
label: 'Current Password', label: 'Current Password',
obscureText: true, obscureText: true,
validator: (v) => Validators.required(v, fieldName: 'Current password'), validator: (v) => Validators.required(v, fieldName: 'Current Password'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(

View File

@ -51,7 +51,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
obscureText: true, obscureText: true,
validator: (v) { validator: (v) {
if (v != _passwordController.text) return 'Passwords do not match'; 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), const SizedBox(height: 24),

View File

@ -47,13 +47,13 @@ class _BranchFormScreenState extends State<BranchFormScreen> {
AppTextField( AppTextField(
controller: _nameController, controller: _nameController,
label: 'Branch Name', label: 'Branch Name',
validator: (v) => Validators.required(v, fieldName: 'Branch name'), validator: (v) => Validators.required(v, fieldName: 'Branch Name'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _codeController, controller: _codeController,
label: 'Branch Code', label: 'Branch Code',
validator: (v) => Validators.required(v, fieldName: 'Branch code'), validator: (v) => Validators.required(v, fieldName: 'Branch Code'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField(controller: _locationController, label: 'Location'), AppTextField(controller: _locationController, label: 'Location'),

View File

@ -51,13 +51,13 @@ class _CompanyFormScreenState extends State<CompanyFormScreen> {
AppTextField( AppTextField(
controller: _nameController, controller: _nameController,
label: 'Company Name', label: 'Company Name',
validator: (v) => Validators.required(v, fieldName: 'Company name'), validator: (v) => Validators.required(v, fieldName: 'Company Name'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _codeController, controller: _codeController,
label: 'Company Code', label: 'Company Code',
validator: (v) => Validators.required(v, fieldName: 'Company code'), validator: (v) => Validators.required(v, fieldName: 'Company Code'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(

View File

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

View File

@ -30,6 +30,7 @@ class GrnDetailScreen extends ConsumerStatefulWidget {
class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> { class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bool _isWorking = false; bool _isWorking = false;
bool _isDownloadingPdf = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -60,6 +61,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
_DetailHeader( _DetailHeader(
grn: grn, grn: grn,
isWorking: _isWorking, isWorking: _isWorking,
isDownloadingPdf: _isDownloadingPdf,
canEdit: canEdit, canEdit: canEdit,
canExport: canExport, canExport: canExport,
onBack: () => context.go(RouteConstants.grn), onBack: () => context.go(RouteConstants.grn),
@ -141,7 +143,7 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
label: 'Cancellation reason *', label: 'Cancellation Reason *',
controller: reasonController, controller: reasonController,
maxLines: 3, maxLines: 3,
), ),
@ -174,7 +176,8 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
} }
Future<void> _downloadPdf(GrnModel grn) async { Future<void> _downloadPdf(GrnModel grn) async {
await _runWorkflow(() async { setState(() => _isDownloadingPdf = true);
try {
final bytes = await ref final bytes = await ref
.read(grnDetailProvider(widget.grnId).notifier) .read(grnDetailProvider(widget.grnId).notifier)
.downloadPdf(); .downloadPdf();
@ -182,7 +185,18 @@ class _GrnDetailScreenState extends ConsumerState<GrnDetailScreen> {
bytes: bytes, bytes: bytes,
fileName: '${grn.grnNumber ?? 'GRN-${grn.id}'}.pdf', 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({ const _DetailHeader({
required this.grn, required this.grn,
required this.isWorking, required this.isWorking,
required this.isDownloadingPdf,
required this.canEdit, required this.canEdit,
required this.canExport, required this.canExport,
required this.onBack, required this.onBack,
@ -214,6 +229,7 @@ class _DetailHeader extends StatelessWidget {
final GrnModel grn; final GrnModel grn;
final bool isWorking; final bool isWorking;
final bool isDownloadingPdf;
final bool canEdit; final bool canEdit;
final bool canExport; final bool canExport;
final VoidCallback onBack; final VoidCallback onBack;
@ -241,7 +257,8 @@ class _DetailHeader extends StatelessWidget {
_HeaderActionButton( _HeaderActionButton(
label: 'PDF', label: 'PDF',
icon: Icons.description_outlined, icon: Icons.description_outlined,
onPressed: isWorking ? null : onPdf, isLoading: isDownloadingPdf,
onPressed: (isWorking || isDownloadingPdf) ? null : onPdf,
), ),
if (canEdit && grn.canEdit) if (canEdit && grn.canEdit)
_HeaderActionButton( _HeaderActionButton(
@ -336,12 +353,14 @@ class _HeaderActionButton extends StatelessWidget {
required this.label, required this.label,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.isLoading = false,
this.destructive = false, this.destructive = false,
}); });
final String label; final String label;
final IconData icon; final IconData icon;
final VoidCallback? onPressed; final VoidCallback? onPressed;
final bool isLoading;
final bool destructive; final bool destructive;
static const double _height = 40; static const double _height = 40;
@ -368,7 +387,17 @@ class _HeaderActionButton extends StatelessWidget {
final child = Row( final child = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 8),
Text(label), Text(label),
], ],
@ -495,11 +524,11 @@ class _ReceiptDetailsCard extends StatelessWidget {
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
final items = [ final items = [
_DetailField( _DetailField(
label: 'GRN date', label: 'GRN Date',
value: DateFormatter.displayDate(grn.grnDate), value: DateFormatter.displayDate(grn.grnDate),
), ),
_DetailField( _DetailField(
label: 'PO number', label: 'PO Number',
value: _displayOrDash(grn.poNumber), value: _displayOrDash(grn.poNumber),
), ),
_DetailField( _DetailField(
@ -511,37 +540,37 @@ class _ReceiptDetailsCard extends StatelessWidget {
value: _displayOrDash(grn.warehouseName), value: _displayOrDash(grn.warehouseName),
), ),
_DetailField( _DetailField(
label: 'Vendor invoice no', label: 'Vendor Invoice No',
value: _displayOrDash(grn.vendorInvoiceNo), value: _displayOrDash(grn.vendorInvoiceNo),
), ),
_DetailField( _DetailField(
label: 'Vendor invoice date', label: 'Vendor Invoice Date',
value: DateFormatter.displayDate(grn.vendorInvoiceDate), value: DateFormatter.displayDate(grn.vendorInvoiceDate),
), ),
_DetailField( _DetailField(
label: 'Vendor invoice amount', label: 'Vendor Invoice Amount',
value: grn.vendorInvoiceAmount != null value: grn.vendorInvoiceAmount != null
? CurrencyFormatter.format(grn.vendorInvoiceAmount!) ? CurrencyFormatter.format(grn.vendorInvoiceAmount!)
: '', : '',
), ),
_DetailField( _DetailField(
label: 'Vehicle no', label: 'Vehicle No',
value: _displayOrDash(grn.vehicleNo), value: _displayOrDash(grn.vehicleNo),
), ),
_DetailField( _DetailField(
label: 'LR no', label: 'LR No',
value: _displayOrDash(grn.lrNo), value: _displayOrDash(grn.lrNo),
), ),
_DetailField( _DetailField(
label: 'LR date', label: 'LR Date',
value: DateFormatter.displayDate(grn.lrDate), value: DateFormatter.displayDate(grn.lrDate),
), ),
_DetailField( _DetailField(
label: 'Received by', label: 'Received By',
value: _userLabel(lookups?.users, grn.receivedBy), value: _userLabel(lookups?.users, grn.receivedBy),
), ),
_DetailField( _DetailField(
label: 'Quality checked by', label: 'Quality Checked By',
value: _userLabel(lookups?.users, grn.qualityCheckedBy), value: _userLabel(lookups?.users, grn.qualityCheckedBy),
), ),
]; ];

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -6,6 +7,7 @@ import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart'; import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart'; import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/formatters.dart'; import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_management_models.dart';
@ -405,7 +407,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
FormRowFour( FormRowFour(
children: [ children: [
_DateField( _DateField(
label: 'GRN date *', label: 'GRN Date *',
value: _grnDate, value: _grnDate,
enabled: !widget.isEditing, enabled: !widget.isEditing,
onTap: widget.isEditing onTap: widget.isEditing
@ -418,7 +420,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
), ),
if (!widget.isEditing) if (!widget.isEditing)
AppSearchableDropdown<String>( AppSearchableDropdown<String>(
label: 'Purchase order *', label: 'Purchase Order *',
value: _selectedPoId, value: _selectedPoId,
hint: 'Select PO', hint: 'Select PO',
searchHint: 'Search PO...', searchHint: 'Search PO...',
@ -447,12 +449,12 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
} }
}, },
validator: (v) => v == null validator: (v) => v == null
? 'Purchase order is required' ? 'Purchase Order Is Required'
: null, : null,
) )
else else
_ReadOnlyField( _ReadOnlyField(
label: 'Purchase order', label: 'Purchase Order',
value: existing?.poNumber ?? '', value: existing?.poNumber ?? '',
), ),
MasterQuickAddDropdown<int>( MasterQuickAddDropdown<int>(
@ -475,7 +477,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
enabled: !widget.isEditing, enabled: !widget.isEditing,
), ),
AppTextField( AppTextField(
label: 'Vendor invoice no', label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController, controller: _vendorInvoiceNoController,
), ),
], ],
@ -483,7 +485,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
FormRowFour( FormRowFour(
children: [ children: [
_DateField( _DateField(
label: 'Vendor invoice date', label: 'Vendor Invoice Date',
value: _vendorInvoiceDate, value: _vendorInvoiceDate,
onTap: () => _pickDate( onTap: () => _pickDate(
current: _vendorInvoiceDate, current: _vendorInvoiceDate,
@ -492,18 +494,27 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
), ),
), ),
AppTextField( AppTextField(
label: 'Vendor invoice amount', label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController, controller: _vendorInvoiceAmountController,
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true, decimal: true,
), ),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}'),
),
],
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Vendor Invoice Amount',
),
), ),
AppTextField( AppTextField(
label: 'Vehicle no', label: 'Vehicle No',
controller: _vehicleNoController, controller: _vehicleNoController,
), ),
AppTextField( AppTextField(
label: 'LR no', label: 'LR No',
controller: _lrNoController, controller: _lrNoController,
), ),
], ],
@ -511,7 +522,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
FormRowFour( FormRowFour(
children: [ children: [
_DateField( _DateField(
label: 'LR date', label: 'LR Date',
value: _lrDate, value: _lrDate,
onTap: () => _pickDate( onTap: () => _pickDate(
current: _lrDate, current: _lrDate,
@ -519,7 +530,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
), ),
), ),
AppSearchableDropdown<int>( AppSearchableDropdown<int>(
label: 'Received by', label: 'Received By',
value: _dropdownValue( value: _dropdownValue(
_normalizeUserId(_receivedById), _normalizeUserId(_receivedById),
userIds, userIds,
@ -531,7 +542,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
setState(() => _receivedById = v), setState(() => _receivedById = v),
), ),
AppSearchableDropdown<int>( AppSearchableDropdown<int>(
label: 'Quality checked by', label: 'Quality Checked By',
value: _dropdownValue( value: _dropdownValue(
_normalizeUserId(_qualityCheckedById), _normalizeUserId(_qualityCheckedById),
userIds, userIds,
@ -616,7 +627,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
final theme = Theme.of(context); final theme = Theme.of(context);
final title = widget.isEditing final title = widget.isEditing
? 'Edit ${existing?.grnNumber ?? 'GRN'}' ? 'Edit ${existing?.grnNumber ?? 'GRN'}'
: 'Create goods received note'; : 'Create Goods Received Note';
final subtitle = widget.isEditing final subtitle = widget.isEditing
? null ? null
: 'Select an approved purchase order, enter receipt details, then confirm quantities.'; : '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( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: LayoutBuilder(
@ -190,7 +189,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search status...', searchHint: 'Search status...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All statuses'), const AppDropdownOption(value: null, label: 'All Statuses'),
...grnStatusOptions.map( ...grnStatusOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2), (e) => AppDropdownOption(value: e.$1, label: e.$2),
), ),
@ -257,15 +256,14 @@ class _GrnDataTable extends StatelessWidget {
flex: 2, flex: 2,
cellBuilder: (_, grn) => Text(grn.vendorName ?? ''), cellBuilder: (_, grn) => Text(grn.vendorName ?? ''),
), ),
AppDataColumn(
label: 'Warehouse',
flex: 2,
cellBuilder: (_, grn) => Text(grn.warehouseName ?? ''),
),
AppDataColumn( AppDataColumn(
label: 'Status', label: 'Status',
flex: 1, flex: 1,
cellBuilder: (_, grn) => GrnStatusChip(status: grn.status, compact: true), cellBuilder: (_, grn) => GrnStatusChip(
status: grn.status,
compact: true,
forTable: true,
),
), ),
AppDataColumn( AppDataColumn(
label: 'Actions', label: 'Actions',

View File

@ -223,9 +223,14 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')), FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,4}')),
]; ];
late final TextEditingController _currentQtyController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentQtyController = TextEditingController(
text: _formatQty(widget.item.currentQty),
);
widget.item.acceptedQtyController.addListener(_onFieldChanged); widget.item.acceptedQtyController.addListener(_onFieldChanged);
widget.item.rejectedQtyController.addListener(_onFieldChanged); widget.item.rejectedQtyController.addListener(_onFieldChanged);
} }
@ -234,10 +239,18 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
void dispose() { void dispose() {
widget.item.acceptedQtyController.removeListener(_onFieldChanged); widget.item.acceptedQtyController.removeListener(_onFieldChanged);
widget.item.rejectedQtyController.removeListener(_onFieldChanged); widget.item.rejectedQtyController.removeListener(_onFieldChanged);
_currentQtyController.dispose();
super.dispose(); super.dispose();
} }
void _onFieldChanged() { 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(); widget.onChanged();
setState(() {}); setState(() {});
} }
@ -319,7 +332,7 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
const SizedBox(height: 8), const SizedBox(height: 8),
FormRow( FormRow(
columnCount: 12, columnCount: 12,
spans: const [1, 1, 1, 2, 3, 4], spans: const [2, 2, 2, 2, 2, 2],
spacing: 8, spacing: 8,
stackBelowWidth: 1100, stackBelowWidth: 1100,
children: [ children: [
@ -377,22 +390,21 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> {
return null; return null;
}, },
), ),
_GrnLineReadOnlyField( AppTextField(
key: ValueKey('$lineKey-current'), key: ValueKey('$lineKey-current'),
controller: _currentQtyController,
label: 'Current', label: 'Current',
value: _formatQty(item.currentQty), isDense: true,
backgroundColor: currentBg, readOnly: true,
fillColor: currentBg,
), ),
AppTextField( AppTextField(
key: ValueKey('$lineKey-rate'), key: ValueKey('$lineKey-rate'),
controller: item.rateController, controller: item.rateController,
label: 'Rate', label: 'Rate',
hint: '0.00', hint: '0.00',
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: _qtyFormatters,
isDense: true, isDense: true,
onChanged: (_) => widget.onChanged(), readOnly: true,
), ),
AppTextField( AppTextField(
key: ValueKey('$lineKey-batch'), 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 { class _GrnLineDateField extends StatelessWidget {
const _GrnLineDateField({ const _GrnLineDateField({
super.key, super.key,

View File

@ -1,20 +1,32 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/grn_model.dart';
import '../../../../shared/widgets/app_status_chip.dart';
class GrnStatusChip extends StatelessWidget { class GrnStatusChip extends StatelessWidget {
const GrnStatusChip({ const GrnStatusChip({
super.key, super.key,
required this.status, required this.status,
this.compact = false, this.compact = false,
this.forTable = false,
}); });
final String status; final String status;
final bool compact; final bool compact;
final bool forTable;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status); final (color, label) = _resolveStatus(status);
if (forTable) {
return TableStatusBadge(
label: label,
color: color,
compact: compact,
fixedWidth: kTableStatusChipWidth,
);
}
return Chip( return Chip(
label: Text( label: Text(
label, label,

View File

@ -14,6 +14,7 @@ import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart'; import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_search_export_bar.dart'; import '../../../../shared/widgets/app_search_export_bar.dart';
import '../../../../shared/widgets/app_status_chip.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/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_side_panel.dart';
@ -185,7 +186,6 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: LayoutBuilder(
@ -286,6 +286,7 @@ class _MasterListTable extends StatelessWidget {
cellBuilder: (_, row) => AppStatusChip( cellBuilder: (_, row) => AppStatusChip(
status: masterStatusValue(row), status: masterStatusValue(row),
compact: true, compact: true,
forTable: true,
), ),
), ),
AppDataColumn( AppDataColumn(
@ -294,24 +295,22 @@ class _MasterListTable extends StatelessWidget {
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
cellBuilder: (_, row) { cellBuilder: (_, row) {
final id = row['id']?.toString(); final id = row['id']?.toString();
return Row( return AppTableActions(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (canEdit) if (canEdit)
IconButton( AppTableActionIcon(
tooltip: 'Edit', tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined), icon: Icons.edit_outlined,
onPressed: id == null ? null : () => onEdit(id), enabled: id != null,
onPressed: () => onEdit(id!),
), ),
if (canDelete) if (canDelete)
IconButton( AppTableActionIcon(
tooltip: 'Delete', tooltip: 'Delete',
icon: Icon( icon: Icons.delete_outline,
Icons.delete_outline, color: theme.colorScheme.error,
color: theme.colorScheme.error, enabled: !isDeleting,
), onPressed: () => onDelete(row),
onPressed: isDeleting ? null : () => onDelete(row),
), ),
], ],
); );

View File

@ -334,9 +334,28 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
final widgets = <Widget>[]; final widgets = <Widget>[];
for (var i = 0; i < regularFields.length; i += 2) { var i = 0;
while (i < regularFields.length) {
final left = regularFields[i]; 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]; final right = regularFields[i + 1];
widgets.add( widgets.add(
SidePanelFormRow( SidePanelFormRow(
@ -344,6 +363,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
right: _buildField(context, field: right, formState: formState), right: _buildField(context, field: right, formState: formState),
), ),
); );
i += 2;
} else { } else {
widgets.add( widgets.add(
Padding( 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; final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit ${def.title.toLowerCase()}' : 'Add ${def.title.toLowerCase()}', title: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}',
footer: Row( footer: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -390,7 +411,7 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppButton( AppButton(
label: widget.isEditing ? 'Update ${def.title.toLowerCase()}' : 'Save ${def.title.toLowerCase()}', label: widget.isEditing ? 'Edit ${def.title}' : 'Add ${def.title}',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: isSubmitting, isLoading: isSubmitting,

View File

@ -392,7 +392,7 @@ class _MasterInlineCreateFormState
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Add ${def.title.toLowerCase()}', 'Add ${def.title}',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -464,7 +464,7 @@ class _MasterInlineCreateFormState
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AppButton( AppButton(
label: 'Save', label: 'Add ${def.title}',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: isSubmitting, isLoading: isSubmitting,

View File

@ -34,6 +34,7 @@ class PurchaseOrderDetailScreen extends ConsumerStatefulWidget {
class _PurchaseOrderDetailScreenState class _PurchaseOrderDetailScreenState
extends ConsumerState<PurchaseOrderDetailScreen> { extends ConsumerState<PurchaseOrderDetailScreen> {
bool _isWorking = false; bool _isWorking = false;
bool _isDownloadingPdf = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -70,6 +71,7 @@ class _PurchaseOrderDetailScreenState
_DetailHeader( _DetailHeader(
order: order, order: order,
isWorking: _isWorking, isWorking: _isWorking,
isDownloadingPdf: _isDownloadingPdf,
canEdit: canEdit, canEdit: canEdit,
canDelete: canDelete, canDelete: canDelete,
canApprove: canApprove, canApprove: canApprove,
@ -288,7 +290,7 @@ class _PurchaseOrderDetailScreenState
} }
Future<void> _downloadPdf(PurchaseOrderModel order) async { Future<void> _downloadPdf(PurchaseOrderModel order) async {
setState(() => _isWorking = true); setState(() => _isDownloadingPdf = true);
try { try {
final bytes = await ref final bytes = await ref
.read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier) .read(purchaseOrderDetailProvider(widget.purchaseOrderId).notifier)
@ -304,7 +306,7 @@ class _PurchaseOrderDetailScreenState
.showSnackBar(SnackBar(content: Text(e.toString()))); .showSnackBar(SnackBar(content: Text(e.toString())));
} }
} finally { } finally {
if (mounted) setState(() => _isWorking = false); if (mounted) setState(() => _isDownloadingPdf = false);
} }
} }
} }
@ -376,6 +378,7 @@ class _DetailHeader extends StatelessWidget {
const _DetailHeader({ const _DetailHeader({
required this.order, required this.order,
required this.isWorking, required this.isWorking,
required this.isDownloadingPdf,
required this.canEdit, required this.canEdit,
required this.canDelete, required this.canDelete,
required this.canApprove, required this.canApprove,
@ -393,6 +396,7 @@ class _DetailHeader extends StatelessWidget {
final PurchaseOrderModel order; final PurchaseOrderModel order;
final bool isWorking; final bool isWorking;
final bool isDownloadingPdf;
final bool canEdit; final bool canEdit;
final bool canDelete; final bool canDelete;
final bool canApprove; final bool canApprove;
@ -426,7 +430,8 @@ class _DetailHeader extends StatelessWidget {
_HeaderActionButton( _HeaderActionButton(
label: 'PDF', label: 'PDF',
icon: Icons.description_outlined, icon: Icons.description_outlined,
onPressed: isWorking ? null : onPdf, isLoading: isDownloadingPdf,
onPressed: (isWorking || isDownloadingPdf) ? null : onPdf,
), ),
if (canEdit && order.canEdit) if (canEdit && order.canEdit)
_HeaderActionButton( _HeaderActionButton(
@ -557,6 +562,7 @@ class _HeaderActionButton extends StatelessWidget {
required this.label, required this.label,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.isLoading = false,
this.filled = false, this.filled = false,
this.destructive = false, this.destructive = false,
}); });
@ -564,6 +570,7 @@ class _HeaderActionButton extends StatelessWidget {
final String label; final String label;
final IconData icon; final IconData icon;
final VoidCallback? onPressed; final VoidCallback? onPressed;
final bool isLoading;
final bool filled; final bool filled;
final bool destructive; final bool destructive;
@ -592,7 +599,19 @@ class _HeaderActionButton extends StatelessWidget {
final child = Row( final child = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 8),
Text(label), Text(label),
], ],
@ -699,11 +718,11 @@ class _OrderDetailsCard extends StatelessWidget {
final width = (constraints.maxWidth - spacing * (cols - 1)) / cols; final width = (constraints.maxWidth - spacing * (cols - 1)) / cols;
final items = [ final items = [
_DetailField( _DetailField(
label: 'PO date', label: 'PO Date',
value: DateFormatter.displayDate(order.poDate), value: DateFormatter.displayDate(order.poDate),
), ),
_DetailField( _DetailField(
label: 'Expected delivery', label: 'Expected Delivery',
value: DateFormatter.displayDate(order.expectedDeliveryDate), value: DateFormatter.displayDate(order.expectedDeliveryDate),
), ),
_DetailField( _DetailField(
@ -711,7 +730,7 @@ class _OrderDetailsCard extends StatelessWidget {
value: _displayOrDash(order.vendorName), value: _displayOrDash(order.vendorName),
), ),
_DetailField( _DetailField(
label: 'PO type', label: 'PO Type',
value: poTypeLabel(order.poType), value: poTypeLabel(order.poType),
), ),
_DetailField( _DetailField(
@ -723,8 +742,8 @@ class _OrderDetailsCard extends StatelessWidget {
value: _displayOrDash(order.warehouseName), value: _displayOrDash(order.warehouseName),
), ),
_DetailField(label: 'Brand', value: brand), _DetailField(label: 'Brand', value: brand),
_DetailField(label: 'Payment term', value: paymentTerm), _DetailField(label: 'Payment Term', value: paymentTerm),
_DetailField(label: 'Delivery term', value: deliveryTerm), _DetailField(label: 'Delivery Term', value: deliveryTerm),
]; ];
return Wrap( return Wrap(
@ -1076,7 +1095,7 @@ class _AmountSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_SummaryRow( _SummaryRow(
label: 'Taxable amount', label: 'Taxable Amount',
value: CurrencyFormatter.format(order.taxableAmount), value: CurrencyFormatter.format(order.taxableAmount),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1086,12 +1105,12 @@ class _AmountSummaryCard extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SummaryRow( _SummaryRow(
label: 'Freight charges', label: 'Freight Charges',
value: CurrencyFormatter.format(order.freightCharges ?? 0), value: CurrencyFormatter.format(order.freightCharges ?? 0),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_SummaryRow( _SummaryRow(
label: 'Other charges', label: 'Other Charges',
value: CurrencyFormatter.format(order.otherCharges ?? 0), value: CurrencyFormatter.format(order.otherCharges ?? 0),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

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

View File

@ -98,7 +98,6 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
), ),
], ],
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: LayoutBuilder(
@ -220,7 +219,7 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
options.sort((a, b) => a.label.compareTo(b.label)); options.sort((a, b) => a.label.compareTo(b.label));
return [ return [
const AppDropdownOption<String?>(value: null, label: 'All statuses'), const AppDropdownOption<String?>(value: null, label: 'All Statuses'),
...options, ...options,
]; ];
} }
@ -277,7 +276,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search type...', searchHint: 'Search type...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All types'), const AppDropdownOption(value: null, label: 'All Types'),
...poTypeOptions.map( ...poTypeOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2), (e) => AppDropdownOption(value: e.$1, label: e.$2),
), ),
@ -345,16 +344,6 @@ class _PoDataTable extends StatelessWidget {
flex: 2, flex: 2,
cellBuilder: (_, order) => Text(order.vendorName ?? ''), 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( AppDataColumn(
label: 'Total', label: 'Total',
flex: 1, flex: 1,
@ -364,7 +353,11 @@ class _PoDataTable extends StatelessWidget {
AppDataColumn( AppDataColumn(
label: 'Status', label: 'Status',
flex: 1, flex: 1,
cellBuilder: (_, order) => PoStatusChip(status: order.status), cellBuilder: (_, order) => PoStatusChip(
status: order.status,
compact: true,
forTable: true,
),
), ),
AppDataColumn( AppDataColumn(
label: 'Actions', label: 'Actions',

View File

@ -1,20 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/widgets/app_status_chip.dart';
class PoStatusChip extends StatelessWidget { class PoStatusChip extends StatelessWidget {
const PoStatusChip({ const PoStatusChip({
super.key, super.key,
required this.status, required this.status,
this.compact = false, this.compact = false,
this.forTable = false,
}); });
final String status; final String status;
final bool compact; final bool compact;
final bool forTable;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status); 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); return _PoBadge(label: label, color: color, compact: compact);
} }

View File

@ -263,7 +263,7 @@ class _PurchaseOrderLineItemsEditorState
widget.onChanged?.call(); widget.onChanged?.call();
}, },
icon: const Icon(Icons.add, size: 18), icon: const Icon(Icons.add, size: 18),
label: const Text('Add line'), label: const Text('Add Line'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary, foregroundColor: theme.colorScheme.primary,
), ),
@ -437,7 +437,7 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
.whereType<AppDropdownOption<int>>() .whereType<AppDropdownOption<int>>()
.toList(); .toList();
final gstOptions = [ final gstOptions = [
const AppDropdownOption<int?>(value: null, label: 'Select GST rate'), const AppDropdownOption<int?>(value: null, label: 'Select GST Rate'),
...widget.gstRates.map((e) { ...widget.gstRates.map((e) {
final id = _parseId(e.id); final id = _parseId(e.id);
if (id == null) return null; if (id == null) return null;

View File

@ -11,9 +11,9 @@ class RbacState {
this.selectedTab = RbacTab.users, this.selectedTab = RbacTab.users,
this.selectedRoleId = 'super_admin', this.selectedRoleId = 'super_admin',
this.searchQuery = '', this.searchQuery = '',
this.roleFilter = 'All roles', this.roleFilter = 'All Roles',
this.departmentFilter = 'All departments', this.departmentFilter = 'All Departments',
this.statusFilter = 'All statuses', this.statusFilter = 'All Statuses',
this.currentPage = 0, this.currentPage = 0,
this.pageSize = 10, this.pageSize = 10,
}); });
@ -45,10 +45,10 @@ class RbacState {
user.email.toLowerCase().contains(q) || user.email.toLowerCase().contains(q) ||
user.employeeCode.toLowerCase().contains(q); user.employeeCode.toLowerCase().contains(q);
final matchesRole = final matchesRole =
roleFilter == 'All roles' || user.roleName == roleFilter; roleFilter == 'All Roles' || user.roleName == roleFilter;
final matchesDept = departmentFilter == 'All departments' || final matchesDept = departmentFilter == 'All Departments' ||
user.department == departmentFilter; user.department == departmentFilter;
final matchesStatus = statusFilter == 'All statuses' || final matchesStatus = statusFilter == 'All Statuses' ||
user.status.label == statusFilter; user.status.label == statusFilter;
return matchesSearch && matchesRole && matchesDept && matchesStatus; return matchesSearch && matchesRole && matchesDept && matchesStatus;
}).toList(); }).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_pagination.dart';
import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_data_table.dart';
import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
@ -176,7 +177,7 @@ class _UsersRoleManagementScreenState
SizedBox( SizedBox(
width: cardWidth.clamp(160, constraints.maxWidth), width: cardWidth.clamp(160, constraints.maxWidth),
child: RbacStatCard( child: RbacStatCard(
label: 'Total users', label: 'Total Users',
value: '${summary?.totalUsers ?? usersState?.total ?? 0}', value: '${summary?.totalUsers ?? usersState?.total ?? 0}',
icon: Icons.people_outline, icon: Icons.people_outline,
color: Color(0xFF2563EB), color: Color(0xFF2563EB),
@ -212,7 +213,7 @@ class _UsersRoleManagementScreenState
SizedBox( SizedBox(
width: cardWidth.clamp(160, constraints.maxWidth), width: cardWidth.clamp(160, constraints.maxWidth),
child: RbacStatCard( child: RbacStatCard(
label: 'Roles defined', label: 'Roles Defined',
value: '${summary?.rolesCount ?? state.roles.length}', value: '${summary?.rolesCount ?? state.roles.length}',
icon: Icons.shield_outlined, icon: Icons.shield_outlined,
color: const Color(0xFF16A34A), color: const Color(0xFF16A34A),
@ -558,13 +559,13 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
final password = await showDialog<String>( final password = await showDialog<String>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Reset password'), title: const Text('Reset Password'),
content: TextField( content: TextField(
controller: controller, controller: controller,
obscureText: true, obscureText: true,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'New temporary password', labelText: 'New Temporary Password',
hintText: 'Min. 8 characters', hintText: 'Min. 8 characters',
), ),
), ),
@ -674,27 +675,27 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
final canExport = ref.can('users', PermissionAction.export); final canExport = ref.can('users', PermissionAction.export);
final canEditUser = ref.can('users', PermissionAction.update); final canEditUser = ref.can('users', PermissionAction.update);
final canDeleteUser = ref.can('users', PermissionAction.delete); 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 = [ final departments = [
'All departments', 'All Departments',
...?filters?.departments.map((d) => d.name), ...?filters?.departments.map((d) => d.name),
]; ];
final statuses = [ final statuses = [
'All statuses', 'All Statuses',
...?filters?.statuses.map((s) => s.name), ...?filters?.statuses.map((s) => s.name),
]; ];
final roleFilter = final roleFilter =
_roleNameForId(usersState.query.roleId, filters) ?? 'All roles'; _roleNameForId(usersState.query.roleId, filters) ?? 'All Roles';
final departmentFilter = _departmentNameForId( final departmentFilter = _departmentNameForId(
usersState.query.departmentId, usersState.query.departmentId,
filters, filters,
) ?? ) ??
'All departments'; 'All Departments';
final statusFilter = _statusLabelForValue( final statusFilter = _statusLabelForValue(
usersState.query.status, usersState.query.status,
filters, filters,
) ?? ) ??
'All statuses'; 'All Statuses';
final page = usersState.query.page; final page = usersState.query.page;
final pageSize = usersState.query.limit; final pageSize = usersState.query.limit;
final total = usersState.total; final total = usersState.total;
@ -731,21 +732,21 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
onSearch: ref.read(usersListProvider.notifier).setSearch, onSearch: ref.read(usersListProvider.notifier).setSearch,
onRoleChanged: (value) { onRoleChanged: (value) {
ref.read(usersListProvider.notifier).setRoleFilter( ref.read(usersListProvider.notifier).setRoleFilter(
value == 'All roles' value == 'All Roles'
? null ? null
: _roleIdForName(value, filters), : _roleIdForName(value, filters),
); );
}, },
onDepartmentChanged: (value) { onDepartmentChanged: (value) {
ref.read(usersListProvider.notifier).setDepartmentFilter( ref.read(usersListProvider.notifier).setDepartmentFilter(
value == 'All departments' value == 'All Departments'
? null ? null
: _departmentIdForName(value, filters), : _departmentIdForName(value, filters),
); );
}, },
onStatusChanged: (value) { onStatusChanged: (value) {
ref.read(usersListProvider.notifier).setStatusFilter( ref.read(usersListProvider.notifier).setStatusFilter(
value == 'All statuses' value == 'All Statuses'
? null ? null
: _statusValueForLabel(value, filters), : _statusValueForLabel(value, filters),
); );
@ -994,138 +995,204 @@ class _RolesTab extends ConsumerWidget {
), ),
), ),
data: (rolesState) { data: (rolesState) {
final roles = rolesState.roles; final roles = rolesState.pagedRoles;
final notifier = ref.read(rolesListProvider.notifier);
return GridView.builder( return AppCard(
key: ValueKey('$themeMode-$brightness'), enableHover: false,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( clipBehavior: Clip.none,
maxCrossAxisExtent: 320, elevation: 0,
mainAxisExtent: 190, shape: RoundedRectangleBorder(
crossAxisSpacing: 16, borderRadius: BorderRadius.circular(12),
mainAxisSpacing: 16, side: BorderSide(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.12),
),
), ),
itemCount: roles.length + (canCreateRole ? 1 : 0), child: Column(
itemBuilder: (context, index) { children: [
if (canCreateRole && index == roles.length) { Expanded(
return AppHoverEffect( child: GridView.builder(
onTap: onNewRole, padding: const EdgeInsets.all(16),
showHoverBorder: false, key: ValueKey('$themeMode-$brightness-${rolesState.page}'),
child: CustomPaint( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
painter: _DashedBorderPainter( maxCrossAxisExtent: 320,
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), mainAxisExtent: 168,
radius: 12, crossAxisSpacing: 16,
mainAxisSpacing: 16,
), ),
child: Column( itemCount: roles.length + (canCreateRole ? 1 : 0),
mainAxisAlignment: MainAxisAlignment.center, itemBuilder: (context, index) {
children: [ if (canCreateRole && index == 0) {
Icon( return AppHoverEffect(
Icons.add, onTap: onNewRole,
size: 32, showHoverBorder: false,
color: Theme.of(context).colorScheme.onSurfaceVariant, child: CustomPaint(
), painter: _DashedBorderPainter(
const SizedBox(height: 8), color: Theme.of(context)
Text( .colorScheme
'New role', .outline
style: Theme.of(context).textTheme.bodyMedium?.copyWith( .withValues(alpha: 0.5),
color: Theme.of(context).colorScheme.onSurfaceVariant, radius: 12,
),
),
],
),
),
);
}
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),
), ),
child: Icon(appearance.icon, color: appearance.color, size: 20), child: Column(
), mainAxisAlignment: MainAxisAlignment.center,
const Spacer(), children: [
if (canEditRole) Icon(
IconButton( Icons.add,
icon: const Icon(Icons.edit_outlined, size: 18), size: 32,
onPressed: () => onEditRole(role), color: Theme.of(context)
visualDensity: VisualDensity.compact, .colorScheme
), .onSurfaceVariant,
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,
), ),
), const SizedBox(height: 8),
const SizedBox(width: 16), Text(
Icon( 'New role',
Icons.vpn_key_outlined, style: Theme.of(context)
size: 14, .textTheme
color: Theme.of(context).colorScheme.onSurfaceVariant, .bodyMedium
), ?.copyWith(
const SizedBox(width: 4), color: Theme.of(context)
Text( .colorScheme
'${role.permissionCount} permissions', .onSurfaceVariant,
style: Theme.of(context).textTheme.bodySmall?.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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final actionColumns = matrix.actionColumns.isNotEmpty final actionColumns = matrix.actionColumns.isNotEmpty
? matrix.actionColumns ? matrix.actionColumns
: (catalog?.isNotEmpty == true : (catalog?.isNotEmpty == true
? catalog!.first.actions ? catalog!.first.actions
: permissionMatrixActionOrder); : permissionMatrixActionOrder);
return LayoutBuilder( return AppDataTable<PermissionMatrixModuleRow>(
builder: (context, constraints) { wrapInCard: false,
final tableMinWidth = 180.0 + (actionColumns.length * 88.0); columns: [
final tableWidth = constraints.maxWidth < tableMinWidth AppDataColumn(
? tableMinWidth label: 'Module',
: constraints.maxWidth; flex: 3,
cellBuilder: (context, module) {
final index = matrix.modules.indexOf(module);
final appearance =
permissionModuleAppearance(module.code, index);
return SingleChildScrollView( return Row(
padding: const EdgeInsets.all(16), children: [
child: SingleChildScrollView( Container(
scrollDirection: Axis.horizontal, width: 28,
child: ConstrainedBox( height: 28,
constraints: BoxConstraints(minWidth: tableWidth - 32), decoration: BoxDecoration(
child: Table( color: appearance.color.withValues(alpha: 0.1),
columnWidths: { borderRadius: BorderRadius.circular(6),
0: const FlexColumnWidth(2.5), ),
for (var i = 0; i < actionColumns.length; i++) child: Icon(
i + 1: const FlexColumnWidth(1), appearance.icon,
}, size: 16,
border: TableBorder( color: appearance.color,
horizontalInside: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.12),
), ),
), ),
children: [ const SizedBox(width: 10),
TableRow( Expanded(
decoration: BoxDecoration( child: AppTableCell.text(
color: Theme.of(context) module.name,
.colorScheme style: theme.textTheme.bodyMedium?.copyWith(
.surfaceContainerHighest fontWeight: FontWeight.w500,
.withValues(alpha: 0.4),
), ),
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); ),
...actionColumns.map(
return TableRow( (action) => AppDataColumn(
children: [ label: permissionActionLabel(action),
Padding( flex: 1,
padding: const EdgeInsets.symmetric(vertical: 12), alignment: Alignment.center,
child: Row( cellBuilder: (context, module) {
children: [ final checked = module.granted[action] ?? false;
Container( return Checkbox(
width: 28, value: checked,
height: 28, visualDensity: VisualDensity.compact,
decoration: BoxDecoration( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
color: appearance.color.withValues(alpha: 0.1), onChanged: (value) => ref
borderRadius: BorderRadius.circular(6), .read(permissionMatrixProvider(roleId).notifier)
), .toggleAction(
child: Icon( module.moduleId,
appearance.icon, action,
size: 16, value ?? false,
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,
),
),
),
);
}),
],
);
}),
],
),
),
), ),
); ),
}, ],
); rows: matrix.modules,
}
}
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,
),
),
); );
} }
} }

View File

@ -228,7 +228,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit user' : 'Add user', title: widget.isEditing ? 'Edit User' : 'Add User',
footer: Row( footer: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -269,16 +269,16 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
SidePanelFormRow( SidePanelFormRow(
left: AppTextField( left: AppTextField(
controller: _nameController, controller: _nameController,
label: 'Full name *', label: 'Full Name *',
hint: 'e.g. Ravi Kumar', hint: 'e.g. Ravi Kumar',
validator: (v) => Validators.required(v, fieldName: 'Name'), validator: (v) => Validators.required(v, fieldName: 'Name'),
), ),
right: AppTextField( right: AppTextField(
controller: _employeeCodeController, controller: _employeeCodeController,
label: 'Employee code *', label: 'Employee Code *',
hint: 'e.g. EMP002', hint: 'e.g. EMP002',
validator: (v) => validator: (v) =>
Validators.required(v, fieldName: 'Employee code'), Validators.required(v, fieldName: 'Employee Code'),
), ),
), ),
SidePanelFormRow( SidePanelFormRow(
@ -360,7 +360,7 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
onChanged: (v) => setState(() => _selectedPlantId = v), onChanged: (v) => setState(() => _selectedPlantId = v),
), ),
right: _buildDropdown( right: _buildDropdown(
label: 'Reporting to', label: 'Reporting To',
value: _selectedReportingToId, value: _selectedReportingToId,
options: formState.managers, options: formState.managers,
hint: formState.managers.isEmpty hint: formState.managers.isEmpty
@ -378,8 +378,8 @@ class _AddUserPanelState extends ConsumerState<AddUserPanel> {
AppTextField( AppTextField(
controller: _passwordController, controller: _passwordController,
label: widget.isEditing label: widget.isEditing
? 'New password' ? 'New Password'
: 'Temporary password *', : 'Temporary Password *',
hint: 'Min. 8 characters', hint: 'Min. 8 characters',
obscureText: true, obscureText: true,
validator: widget.isEditing validator: widget.isEditing

View File

@ -98,7 +98,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false; final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit role' : 'Create new role', title: widget.isEditing ? 'Edit Role' : 'Create New Role',
footer: Row( footer: Row(
children: [ children: [
Expanded( Expanded(
@ -157,7 +157,7 @@ class _RoleFormPanelState extends ConsumerState<RoleFormPanel> {
children: [ children: [
AppTextField( AppTextField(
controller: _nameController, controller: _nameController,
label: 'Role name *', label: 'Role Name *',
hint: 'e.g. QC Manager', hint: 'e.g. QC Manager',
validator: Validators.roleName, validator: Validators.roleName,
inputFormatters: Validators.roleNameInput, inputFormatters: Validators.roleNameInput,

View File

@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/widgets/app_data_table.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/models/user_management_models.dart';
import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_card.dart';
@ -212,24 +213,41 @@ class UserRolesCell extends StatelessWidget {
class EmployeeCodeBadge extends StatelessWidget { class EmployeeCodeBadge extends StatelessWidget {
const EmployeeCodeBadge({super.key, required this.code}); const EmployeeCodeBadge({super.key, required this.code});
/// Uniform width for employee code pills in data tables.
static const double tableWidth = 108;
final String code; final String code;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( final display = code.trim().isEmpty ? '' : code.trim();
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( return SizedBox(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), width: tableWidth,
borderRadius: BorderRadius.circular(20), child: Container(
), height: 22,
child: AppTableCell.text( padding: const EdgeInsets.symmetric(horizontal: 8),
code, decoration: BoxDecoration(
style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
fontWeight: FontWeight.w600, borderRadius: BorderRadius.circular(20),
color: theme.colorScheme.onSurfaceVariant, ),
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) { Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant; final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Row( return AppTableActions(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (canEdit) if (canEdit)
_UserActionIcon( AppTableActionIcon(
tooltip: 'Edit user', tooltip: 'Edit user',
icon: Icons.edit_outlined, icon: Icons.edit_outlined,
color: muted, color: muted,
onPressed: onEdit, onPressed: onEdit,
), ),
if (canResetPassword) if (canResetPassword)
_UserActionIcon( AppTableActionIcon(
tooltip: 'Reset password', tooltip: 'Reset password',
icon: Icons.vpn_key_outlined, icon: Icons.vpn_key_outlined,
color: muted, color: muted,
onPressed: onResetPassword, onPressed: onResetPassword,
), ),
if (canDeactivate) if (canDeactivate)
_UserActionIcon( AppTableActionIcon(
tooltip: 'Deactivate user', tooltip: 'Deactivate user',
icon: Icons.person_off_outlined, icon: Icons.person_off_outlined,
color: muted, 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 { class RolePill extends StatelessWidget {
const RolePill({ const RolePill({
super.key, super.key,

View File

@ -80,7 +80,7 @@ class _DepreciationReportScreenState
initialDate: query.asOfDate ?? now, initialDate: query.asOfDate ?? now,
firstDate: DateTime(now.year - 20), firstDate: DateTime(now.year - 20),
lastDate: DateTime(now.year + 1), lastDate: DateTime(now.year + 1),
helpText: 'As of date', helpText: 'As Of Date',
); );
if (picked == null) return; if (picked == null) return;
ref.read(depreciationReportProvider.notifier).setAsOfDate(picked); ref.read(depreciationReportProvider.notifier).setAsOfDate(picked);
@ -165,7 +165,7 @@ class _DepreciationReportScreenState
], ],
), ),
_SummaryStrip(summary: state.summary, asOfDate: state.asOfDate), _SummaryStrip(summary: state.summary, asOfDate: state.asOfDate),
const SizedBox(height: 16), const SizedBox(height: 12),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: LayoutBuilder(
@ -496,7 +496,7 @@ class _FiltersBarState extends State<_FiltersBar> {
final asOfEmpty = query.asOfDate == null; final asOfEmpty = query.asOfDate == null;
final asOfField = AppFilterDateField( final asOfField = AppFilterDateField(
label: 'As of date', label: 'As Of Date',
value: asOfEmpty ? '' : DateFormatter.displayDate(query.asOfDate), value: asOfEmpty ? '' : DateFormatter.displayDate(query.asOfDate),
placeholder: 'Select date', placeholder: 'Select date',
icon: Icons.calendar_today_outlined, icon: Icons.calendar_today_outlined,
@ -508,7 +508,7 @@ class _FiltersBarState extends State<_FiltersBar> {
final purchaseEmpty = final purchaseEmpty =
query.purchaseDateFrom == null && query.purchaseDateTo == null; query.purchaseDateFrom == null && query.purchaseDateTo == null;
final purchaseField = AppFilterDateField( final purchaseField = AppFilterDateField(
label: 'Purchase dates', label: 'Purchase Dates',
value: purchaseEmpty value: purchaseEmpty
? '' ? ''
: '${DateFormatter.displayDate(query.purchaseDateFrom)} ${DateFormatter.displayDate(query.purchaseDateTo)}', : '${DateFormatter.displayDate(query.purchaseDateFrom)} ${DateFormatter.displayDate(query.purchaseDateTo)}',

View File

@ -64,7 +64,6 @@ class _PermissionMatrixScreenState extends ConsumerState<PermissionMatrixScreen>
), ),
], ],
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: context.isMobile child: context.isMobile
? _MatrixCardList(roleId: widget.roleId, matrix: matrix) ? _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_pagination.dart';
import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_table_shell.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/error_view.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../providers/roles_provider.dart'; import '../providers/roles_provider.dart';
@ -54,7 +55,6 @@ class _RoleListScreenState extends ConsumerState<RoleListScreen> {
title: 'Roles', title: 'Roles',
subtitle: 'Manage roles and permission assignments', subtitle: 'Manage roles and permission assignments',
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: SizedBox( toolbar: SizedBox(
@ -135,12 +135,14 @@ class _RoleDataTable extends StatelessWidget {
label: 'Actions', label: 'Actions',
flex: 1, flex: 1,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
cellBuilder: (_, r) => Align( cellBuilder: (_, r) => AppTableActions(
alignment: Alignment.centerRight, children: [
child: TextButton( AppTableActionIcon(
onPressed: () => onOpen(r), tooltip: 'View Matrix',
child: const Text('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); if (data is Map) return Map<String, dynamic>.from(data);
// Some responses return the entity at the root alongside success/message. // Some responses return the entity at the root alongside success/message.
if (root.containsKey('org_name') || if (root.containsKey('org_name') ||
root.containsKey('gstin') ||
root.containsKey('smtp_host') || root.containsKey('smtp_host') ||
root.containsKey('logo_url') || root.containsKey('logo_url') ||
root.containsKey('logo')) { root.containsKey('logo') ||
root.containsKey('favicon_url') ||
root.containsKey('favicon')) {
return root; return root;
} }
return null; return null;
@ -87,6 +90,53 @@ class SettingsRemoteDataSource {
return null; 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` /// GET `/settings/email`
Future<EmailConfigurationSettings?> fetchEmail() async { Future<EmailConfigurationSettings?> fetchEmail() async {
final response = await _dio.get(ApiEndpoints.settingsEmail); final response = await _dio.get(ApiEndpoints.settingsEmail);

View File

@ -58,6 +58,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
final merged = (current?.companyProfile ?? const CompanyProfileSettings()) final merged = (current?.companyProfile ?? const CompanyProfileSettings())
.copyWith( .copyWith(
companyName: remoteProfile.companyName, companyName: remoteProfile.companyName,
gstNumber: remoteProfile.gstNumber,
address: remoteProfile.address, address: remoteProfile.address,
city: remoteProfile.city, city: remoteProfile.city,
state: remoteProfile.state, state: remoteProfile.state,
@ -68,6 +69,9 @@ class SettingsRepositoryImpl implements SettingsRepository {
logoUrl: remoteProfile.logoUrl.isNotEmpty logoUrl: remoteProfile.logoUrl.isNotEmpty
? remoteProfile.logoUrl ? remoteProfile.logoUrl
: current?.companyProfile.logoUrl ?? '', : current?.companyProfile.logoUrl ?? '',
faviconUrl: remoteProfile.faviconUrl.isNotEmpty
? remoteProfile.faviconUrl
: current?.companyProfile.faviconUrl ?? '',
); );
await local.write((current ?? const AppSettings()).copyWith( await local.write((current ?? const AppSettings()).copyWith(
companyProfile: merged, companyProfile: merged,
@ -97,7 +101,12 @@ class SettingsRepositoryImpl implements SettingsRepository {
email: saved.email.isNotEmpty ? saved.email : profile.email, email: saved.email.isNotEmpty ? saved.email : profile.email,
phone: saved.phone.isNotEmpty ? saved.phone : profile.phone, phone: saved.phone.isNotEmpty ? saved.phone : profile.phone,
website: saved.website.isNotEmpty ? saved.website : profile.website, website: saved.website.isNotEmpty ? saved.website : profile.website,
gstNumber: saved.gstNumber.isNotEmpty
? saved.gstNumber
: profile.gstNumber,
logoUrl: saved.logoUrl.isNotEmpty ? saved.logoUrl : profile.logoUrl, logoUrl: saved.logoUrl.isNotEmpty ? saved.logoUrl : profile.logoUrl,
faviconUrl:
saved.faviconUrl.isNotEmpty ? saved.faviconUrl : profile.faviconUrl,
); );
await local.write(current.copyWith(companyProfile: merged)); await local.write(current.copyWith(companyProfile: merged));
return merged; return merged;
@ -112,6 +121,16 @@ class SettingsRepositoryImpl implements SettingsRepository {
return safeApiCall<String?>(() => remote.uploadCompanyLogo(bytes, filename)); 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 @override
Future<Result<EmailConfigurationSettings>> fetchEmailSettings() async { Future<Result<EmailConfigurationSettings>> fetchEmailSettings() async {
return safeApiCall(() async { return safeApiCall(() async {

View File

@ -150,6 +150,7 @@ class CompanyProfileSettings {
/// Payload for `PUT /settings/company` ([CompanySettingsBody]). /// Payload for `PUT /settings/company` ([CompanySettingsBody]).
Map<String, dynamic> toApiJson() => { Map<String, dynamic> toApiJson() => {
'org_name': companyName, 'org_name': companyName,
'gstin': gstNumber,
'mobile': phone, 'mobile': phone,
'email': email, 'email': email,
'website': website, 'website': website,
@ -169,7 +170,9 @@ class CompanyProfileSettings {
'', '',
companyCode: json['companyCode'] as String? ?? '', companyCode: json['companyCode'] as String? ?? '',
registrationNumber: json['registrationNumber'] 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? ?? '', address: json['address'] as String? ?? '',
city: json['city'] as String? ?? '', city: json['city'] as String? ?? '',
state: json['state'] as String? ?? '', state: json['state'] as String? ?? '',
@ -183,7 +186,12 @@ class CompanyProfileSettings {
json['logo'] as String?, 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, List<int> bytes,
String filename, String filename,
); );
Future<Result<String?>> uploadCompanyFavicon(
List<int> bytes,
String filename,
);
Future<Result<EmailConfigurationSettings>> fetchEmailSettings(); Future<Result<EmailConfigurationSettings>> fetchEmailSettings();
Future<Result<EmailConfigurationSettings>> saveEmailSettings( Future<Result<EmailConfigurationSettings>> saveEmailSettings(
EmailConfigurationSettings email, EmailConfigurationSettings email,

View File

@ -6,6 +6,7 @@ import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/branding_config.dart'; import '../../../../core/theme/branding_config.dart';
import '../../../../core/theme/theme_provider.dart'; import '../../../../core/theme/theme_provider.dart';
import '../../../../core/utils/favicon_store.dart'; import '../../../../core/utils/favicon_store.dart';
import '../../../../core/utils/favicon_updater.dart';
import '../../../../core/utils/media_url.dart'; import '../../../../core/utils/media_url.dart';
import '../../data/datasources/settings_local_data_source.dart'; import '../../data/datasources/settings_local_data_source.dart';
import '../../data/datasources/settings_remote_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 { Future<void> _load() async {
final result = await _getSettings(); final result = await _getSettings();
state = result.data ?? const AppSettings(); state = result.data ?? const AppSettings();
_faviconStore.apply(); await _syncFavicon(state.companyProfile.faviconUrl);
await _syncMainLogo(state.companyProfile); await _syncMainLogo(state.companyProfile);
} }
@ -109,6 +119,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
if (result.failure == null && result.data != null) { if (result.failure == null && result.data != null) {
state = state.copyWith(companyProfile: result.data!); state = state.copyWith(companyProfile: result.data!);
await _syncMainLogo(result.data!); await _syncMainLogo(result.data!);
await _syncFavicon(result.data!.faviconUrl);
} }
return result.failure; return result.failure;
} finally { } finally {
@ -146,6 +157,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
state = state.copyWith(companyProfile: result.data!); state = state.copyWith(companyProfile: result.data!);
await _saveSettings(state); await _saveSettings(state);
await _syncMainLogo(result.data!); await _syncMainLogo(result.data!);
await _syncFavicon(result.data!.faviconUrl);
} }
return null; return null;
} }
@ -167,6 +179,25 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
return result; 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. /// Applies a local/preview logo to company profile + main app branding.
Future<void> applyLocalCompanyLogo(String logoUrl) async { Future<void> applyLocalCompanyLogo(String logoUrl) async {
final resolved = resolveMediaUrl(logoUrl) ?? logoUrl; final resolved = resolveMediaUrl(logoUrl) ?? logoUrl;
@ -176,6 +207,15 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await _syncMainLogo(profile); 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 { Future<void> updateUiPreferences(UiPreferencesSettings prefs) async {
await _persist(state.copyWith(uiPreferences: prefs)); await _persist(state.copyWith(uiPreferences: prefs));
} }

View File

@ -128,7 +128,7 @@ class AppearanceSettingsScreen extends ConsumerWidget {
title: 'UI Preferences', title: 'UI Preferences',
children: [ children: [
SettingsSwitchTile( SettingsSwitchTile(
title: 'Sidebar Expanded by Default', title: 'Sidebar Expanded By Default',
subtitle: 'Show full sidebar labels on login', subtitle: 'Show full sidebar labels on login',
value: uiPrefs.sidebarExpanded, value: uiPrefs.sidebarExpanded,
onChanged: (v) => ref onChanged: (v) => ref

View File

@ -45,6 +45,7 @@ class _CompanyProfileSettingsScreenState
bool _loading = true; bool _loading = true;
bool _saving = false; bool _saving = false;
bool _uploadingLogo = false; bool _uploadingLogo = false;
bool _uploadingFavicon = false;
@override @override
void initState() { 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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Company profile saved')), 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 { Future<void> _pickLogo() async {
final result = await FilePicker.pickFiles( final result = await FilePicker.pickFiles(
type: FileType.image, type: FileType.image,
@ -256,9 +231,68 @@ class _CompanyProfileSettingsScreenState
setState(() => _logoUrlController.text = dataUri); setState(() => _logoUrlController.text = dataUri);
} }
Future<void> _pickFavicon() => _pickImage((dataUri) { Future<void> _pickFavicon() async {
_faviconUrlController.text = dataUri; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -281,7 +315,7 @@ class _CompanyProfileSettingsScreenState
controller: _nameController, controller: _nameController,
label: 'Company Name', label: 'Company Name',
validator: (v) => validator: (v) =>
Validators.required(v, fieldName: 'Company name'), Validators.required(v, fieldName: 'Company Name'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
@ -296,7 +330,7 @@ class _CompanyProfileSettingsScreenState
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _gstController, controller: _gstController,
label: 'GST/VAT Number', label: 'GSTIN',
validator: Validators.optionalGstin, validator: Validators.optionalGstin,
inputFormatters: Validators.gstinInput, inputFormatters: Validators.gstinInput,
), ),
@ -333,7 +367,7 @@ class _CompanyProfileSettingsScreenState
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _phoneController, controller: _phoneController,
label: 'Phone', label: 'Mobile',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
validator: Validators.optionalMobile, validator: Validators.optionalMobile,
inputFormatters: Validators.mobileInput, inputFormatters: Validators.mobileInput,
@ -388,7 +422,7 @@ class _CompanyProfileSettingsScreenState
SettingsFormCard( SettingsFormCard(
title: 'Favicon Upload', title: 'Favicon Upload',
subtitle: 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: [ children: [
Center( Center(
child: SidebarLogo( child: SidebarLogo(
@ -409,9 +443,18 @@ class _CompanyProfileSettingsScreenState
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _pickFavicon, onPressed: _uploadingFavicon ? null : _pickFavicon,
icon: const Icon(Icons.upload_file), icon: _uploadingFavicon
label: const Text('Upload Favicon'), ? 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', label: 'SMTP Host',
hint: 'smtp.gmail.com', hint: 'smtp.gmail.com',
validator: (v) => validator: (v) =>
Validators.required(v, fieldName: 'SMTP host'), Validators.required(v, fieldName: 'SMTP Host'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
@ -153,7 +153,7 @@ class _EmailConfigurationScreenState
label: 'SMTP Port', label: 'SMTP Port',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
validator: (v) => validator: (v) =>
Validators.required(v, fieldName: 'SMTP port'), Validators.required(v, fieldName: 'SMTP Port'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(

View File

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

View File

@ -175,13 +175,13 @@ class _UserFormScreenState extends ConsumerState<UserFormScreen> {
AppTextField( AppTextField(
controller: _firstNameController, controller: _firstNameController,
label: 'First Name', label: 'First Name',
validator: (v) => Validators.required(v, fieldName: 'First name'), validator: (v) => Validators.required(v, fieldName: 'First Name'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(
controller: _lastNameController, controller: _lastNameController,
label: 'Last Name', label: 'Last Name',
validator: (v) => Validators.required(v, fieldName: 'Last name'), validator: (v) => Validators.required(v, fieldName: 'Last Name'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppTextField( AppTextField(

View File

@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/error_view.dart'; import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/kpi_card.dart'; import '../../../../shared/widgets/kpi_card.dart';
import '../../../../shared/widgets/app_table_shell.dart'; import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/page_header.dart';
import '../widgets/user_rich_data_table.dart'; import '../widgets/user_rich_data_table.dart';
import '../providers/users_provider.dart'; import '../providers/users_provider.dart';
@ -65,10 +66,9 @@ class _UserListScreenState extends ConsumerState<UserListScreen> {
], ],
), ),
if (state.summary != null) ...[ if (state.summary != null) ...[
const SizedBox(height: 16), const SizedBox(height: 12),
_SummaryStrip(summary: state.summary!), _SummaryStrip(summary: state.summary!),
], ],
const SizedBox(height: 16),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: _FiltersBar( toolbar: _FiltersBar(
@ -231,7 +231,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search status...', searchHint: 'Search status...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All statuses'), const AppDropdownOption(value: null, label: 'All Statuses'),
...(filters?.statuses ?? []) ...(filters?.statuses ?? [])
.map((s) => AppDropdownOption(value: s.id, label: s.name)), .map((s) => AppDropdownOption(value: s.id, label: s.name)),
], ],
@ -243,7 +243,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search role...', searchHint: 'Search role...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All roles'), const AppDropdownOption(value: null, label: 'All Roles'),
...(filters?.roles ?? []).map( ...(filters?.roles ?? []).map(
(r) => AppDropdownOption( (r) => AppDropdownOption(
value: int.tryParse(r.id), value: int.tryParse(r.id),
@ -259,7 +259,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search department...', searchHint: 'Search department...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All departments'), const AppDropdownOption(value: null, label: 'All Departments'),
...(filters?.departments ?? []).map( ...(filters?.departments ?? []).map(
(d) => AppDropdownOption( (d) => AppDropdownOption(
value: int.tryParse(d.id), value: int.tryParse(d.id),
@ -413,27 +413,31 @@ class _UserActions extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( final errorColor = Theme.of(context).colorScheme.error;
spacing: 4,
return AppTableActions(
children: [ children: [
IconButton( AppTableActionIcon(
tooltip: 'View', tooltip: 'View',
icon: const Icon(Icons.visibility_outlined), icon: Icons.visibility_outlined,
onPressed: () => onView(user), onPressed: () => onView(user),
), ),
IconButton( AppTableActionIcon(
tooltip: 'Edit', tooltip: 'Edit',
icon: const Icon(Icons.edit_outlined), icon: Icons.edit_outlined,
onPressed: () => onEdit(user), onPressed: () => onEdit(user),
), ),
IconButton( AppTableActionIcon(
tooltip: user.status == 'active' ? 'Deactivate' : 'Activate', 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), onPressed: () => onToggleStatus(user),
), ),
IconButton( AppTableActionIcon(
tooltip: 'Delete', tooltip: 'Delete',
icon: const Icon(Icons.delete_outline), icon: Icons.delete_outline,
color: errorColor,
onPressed: () => onDeactivate(user), onPressed: () => onDeactivate(user),
), ),
], ],

View File

@ -245,7 +245,7 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
controller: _confirmPasswordController, controller: _confirmPasswordController,
label: 'Confirm Password', label: 'Confirm Password',
obscureText: true, obscureText: true,
validator: (v) => Validators.required(v, fieldName: 'Confirm password'), validator: (v) => Validators.required(v, fieldName: 'Confirm Password'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AppButton( AppButton(

View File

@ -49,11 +49,13 @@ class UserRichDataTable extends StatelessWidget {
label: 'Employee Code', label: 'Employee Code',
sortKey: 'employee_code', sortKey: 'employee_code',
flex: 1, flex: 1,
alignment: Alignment.centerRight,
cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode), cellBuilder: (_, user) => EmployeeCodeBadge(code: user.employeeCode),
), ),
AppDataColumn( AppDataColumn(
label: 'Role', label: 'Role',
flex: 2, flex: 2,
padding: const EdgeInsets.only(left: 8),
cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames), cellBuilder: (_, user) => UserRolesCell(roles: user.roleNames),
), ),
AppDataColumn( AppDataColumn(
@ -61,11 +63,6 @@ class UserRichDataTable extends StatelessWidget {
flex: 2, flex: 2,
cellBuilder: (_, user) => Text(user.departmentLabel), cellBuilder: (_, user) => Text(user.departmentLabel),
), ),
AppDataColumn(
label: 'Plant',
flex: 2,
cellBuilder: (_, user) => Text(user.plantLabel),
),
AppDataColumn( AppDataColumn(
label: 'Last Login', label: 'Last Login',
flex: 2, flex: 2,
@ -79,16 +76,17 @@ class UserRichDataTable extends StatelessWidget {
AppDataColumn( AppDataColumn(
label: 'Status', label: 'Status',
flex: 1, flex: 1,
cellBuilder: (_, user) => AppStatusChip(status: user.status), cellBuilder: (_, user) => AppStatusChip(
status: user.status,
compact: true,
forTable: true,
),
), ),
AppDataColumn( AppDataColumn(
label: 'Actions', label: 'Actions',
flex: 1, flex: 1,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
cellBuilder: (context, user) => Align( cellBuilder: (context, user) => actionsBuilder(context, user),
alignment: Alignment.centerRight,
child: actionsBuilder(context, user),
),
), ),
], ],
rows: users, rows: users,

View File

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

View File

@ -85,7 +85,6 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
Expanded( Expanded(
child: AppTableShell( child: AppTableShell(
toolbar: LayoutBuilder( toolbar: LayoutBuilder(
@ -204,7 +203,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search status...', searchHint: 'Search status...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All statuses'), const AppDropdownOption(value: null, label: 'All Statuses'),
...vendorStatusOptions.map( ...vendorStatusOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2), (e) => AppDropdownOption(value: e.$1, label: e.$2),
), ),
@ -217,7 +216,7 @@ class _FiltersBar extends StatelessWidget {
searchHint: 'Search vendor type...', searchHint: 'Search vendor type...',
isDense: true, isDense: true,
options: [ options: [
const AppDropdownOption(value: null, label: 'All types'), const AppDropdownOption(value: null, label: 'All Types'),
...vendorTypeOptions.map( ...vendorTypeOptions.map(
(e) => AppDropdownOption(value: e.$1, label: e.$2), (e) => AppDropdownOption(value: e.$1, label: e.$2),
), ),
@ -295,6 +294,8 @@ class _VendorDataTable extends StatelessWidget {
flex: 1, flex: 1,
cellBuilder: (_, vendor) => AppStatusChip( cellBuilder: (_, vendor) => AppStatusChip(
status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'), status: vendor.status ?? (vendor.isActive ? 'active' : 'inactive'),
compact: true,
forTable: true,
), ),
), ),
AppDataColumn( AppDataColumn(

View File

@ -186,7 +186,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
widget.isEditing ? ref.watch(vendorFormProvider(widget.vendorId)) : null; widget.isEditing ? ref.watch(vendorFormProvider(widget.vendorId)) : null;
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit vendor' : 'Add vendor', title: widget.isEditing ? 'Edit Vendor' : 'Add Vendor',
footer: Row( footer: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -198,7 +198,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
AppButton( AppButton(
label: widget.isEditing ? 'Update vendor' : 'Save vendor', label: widget.isEditing ? 'Update Vendor' : 'Save Vendor',
expand: false, expand: false,
icon: Icons.check, icon: Icons.check,
isLoading: _isSubmitting, isLoading: _isSubmitting,
@ -240,7 +240,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
AppTextField( AppTextField(
controller: _nameController, controller: _nameController,
label: 'Vendor Name *', label: 'Vendor Name *',
validator: (v) => Validators.required(v, fieldName: 'Vendor name'), validator: (v) => Validators.required(v, fieldName: 'Vendor Name'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
AppDropdown<String>( AppDropdown<String>(
@ -278,11 +278,11 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
loading: () => const LinearProgressIndicator(), loading: () => const LinearProgressIndicator(),
error: (_, __) => AppTextField( error: (_, __) => AppTextField(
controller: TextEditingController(text: _sourceOfSupply ?? ''), controller: TextEditingController(text: _sourceOfSupply ?? ''),
label: 'Source of Supply', label: 'Source Of Supply',
onChanged: (v) => _sourceOfSupply = v, onChanged: (v) => _sourceOfSupply = v,
), ),
data: (options) => AppSearchableDropdown<String>( data: (options) => AppSearchableDropdown<String>(
label: 'Source of Supply', label: 'Source Of Supply',
value: _sourceOfSupply, value: _sourceOfSupply,
searchHint: 'Search state...', searchHint: 'Search state...',
options: options options: options
@ -316,7 +316,7 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
), ),
right: AppTextField( right: AppTextField(
controller: _creditDaysController, controller: _creditDaysController,
label: 'Credit Period (days)', label: 'Credit Period (Days)',
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
), ),

View File

@ -147,7 +147,7 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit address' : 'Add address', title: widget.isEditing ? 'Edit Address' : 'Add Address',
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
@ -289,7 +289,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit contact' : 'Add contact', title: widget.isEditing ? 'Edit Contact' : 'Add Contact',
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
@ -303,7 +303,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
AppTextField( AppTextField(
controller: _nameController, controller: _nameController,
label: 'Contact Name *', label: 'Contact Name *',
validator: (v) => Validators.required(v, fieldName: 'Contact name'), validator: (v) => Validators.required(v, fieldName: 'Contact Name'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
AppTextField(controller: _designationController, label: 'Designation'), AppTextField(controller: _designationController, label: 'Designation'),
@ -326,7 +326,7 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Primary contact'), title: const Text('Primary Contact'),
value: _isPrimary, value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v), onChanged: (v) => setState(() => _isPrimary = v),
), ),
@ -437,7 +437,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SidePanelScaffold( return SidePanelScaffold(
title: widget.isEditing ? 'Edit bank detail' : 'Add bank detail', title: widget.isEditing ? 'Edit Bank Detail' : 'Add Bank Detail',
footer: _panelFooter( footer: _panelFooter(
context, context,
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
@ -451,7 +451,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
AppTextField( AppTextField(
controller: _bankNameController, controller: _bankNameController,
label: 'Bank Name *', label: 'Bank Name *',
validator: (v) => Validators.required(v, fieldName: 'Bank name'), validator: (v) => Validators.required(v, fieldName: 'Bank Name'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
AppTextField(controller: _branchController, label: 'Branch'), AppTextField(controller: _branchController, label: 'Branch'),
@ -477,7 +477,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
controller: _holderNameController, controller: _holderNameController,
label: 'Account Holder *', label: 'Account Holder *',
validator: (v) => validator: (v) =>
Validators.required(v, fieldName: 'Account holder name'), Validators.required(v, fieldName: 'Account Holder Name'),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -492,7 +492,7 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Primary account'), title: const Text('Primary Account'),
value: _isPrimary, value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v), onChanged: (v) => setState(() => _isPrimary = v),
), ),

View File

@ -12,6 +12,7 @@ class AppDataColumn<T> {
this.sortKey, this.sortKey,
this.flex = 1, this.flex = 1,
this.alignment = Alignment.centerLeft, this.alignment = Alignment.centerLeft,
this.padding = EdgeInsets.zero,
}); });
final String label; final String label;
@ -19,6 +20,7 @@ class AppDataColumn<T> {
final String? sortKey; final String? sortKey;
final int flex; final int flex;
final Alignment alignment; final Alignment alignment;
final EdgeInsets padding;
} }
/// Helpers for table cell content single-line text with ellipsis and tooltip. /// 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( final body = ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shrinkWrap: shrinkWrap, shrinkWrap: shrinkWrap,
clipBehavior: Clip.none,
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (context, index) => _TableDataRow<T>( 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 final table = shrinkWrap
? Column( ? Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -121,7 +125,9 @@ class AppDataTable<T> extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
header, header,
Expanded(child: body), Expanded(
child: ClipRect(child: body),
),
], ],
); );
@ -156,11 +162,7 @@ class _TableHeaderRow<T> extends StatelessWidget {
width: double.infinity, width: double.infinity,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
// Opaque so scrolling rows never show through the sticky header. color: theme.colorScheme.surfaceContainerHighest,
color: Color.alphaBlend(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
theme.colorScheme.surface,
),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.12), color: theme.colorScheme.outline.withValues(alpha: 0.12),
@ -211,9 +213,12 @@ class _TableHeaderRow<T> extends StatelessWidget {
return Expanded( return Expanded(
flex: col.flex, flex: col.flex,
child: Align( child: Padding(
alignment: col.alignment, padding: col.padding,
child: header, child: Align(
alignment: col.alignment,
child: header,
),
), ),
); );
}).toList(), }).toList(),
@ -242,6 +247,7 @@ class _TableDataRow<T> extends StatelessWidget {
width: double.infinity, width: double.infinity,
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.08), color: theme.colorScheme.outline.withValues(alpha: 0.08),
@ -255,11 +261,14 @@ class _TableDataRow<T> extends StatelessWidget {
children: columns.map((col) { children: columns.map((col) {
return Expanded( return Expanded(
flex: col.flex, flex: col.flex,
child: Align( child: Padding(
alignment: col.alignment, padding: col.padding,
child: _TableCellSlot( child: Align(
alignment: col.alignment, 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( child: Align(
alignment: alignment, alignment: alignment,
widthFactor: 1, widthFactor: 1,
heightFactor: 1,
child: _coerceTableCell(child, context), child: _coerceTableCell(child, context),
), ),
); );

View File

@ -9,6 +9,8 @@ import '../../core/constants/enums.dart';
import '../../core/constants/route_constants.dart'; import '../../core/constants/route_constants.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/theme_provider.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 '../../modules/settings/presentation/providers/settings_provider.dart';
import '../models/user_model.dart'; import '../models/user_model.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
@ -268,29 +270,37 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
companyProfileLogo: companyProfile.logoUrl, companyProfileLogo: companyProfile.logoUrl,
brandingLogo: branding.logoUrl, brandingLogo: branding.logoUrl,
); );
final title = resolveSidebarTitle( final faviconUrl = resolveSidebarFaviconUrl(
companyName: companyProfile.companyName, companyProfileFavicon: companyProfile.faviconUrl,
fallback: AppConstants.appName, storedFavicon: FaviconStore(ref.watch(sharedPreferencesProvider)).read(),
); );
if (isNarrow) { if (isNarrow) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(6, 16, 6, 0), padding: const EdgeInsets.fromLTRB(8, 16, 8, 0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SidebarLogo(logoUrl: logoUrl, size: 32), SidebarLogo(
logoUrl: faviconUrl,
size: 24,
fit: BoxFit.contain,
showBackground: false,
),
if (widget.onToggleCollapse != null) ...[ if (widget.onToggleCollapse != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
IconButton( IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 32), constraints:
const BoxConstraints.tightFor(width: 32, height: 32),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: const Icon(Icons.chevron_right, size: 18), icon: const Icon(Icons.chevron_right, size: 18),
tooltip: 'Expand sidebar', tooltip: 'Expand sidebar',
onPressed: widget.onToggleCollapse, onPressed: widget.onToggleCollapse,
), ),
], ],
const SizedBox(height: 12),
_LogoDivider(theme: theme),
], ],
), ),
); );
@ -302,18 +312,19 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: SidebarLogo( child: SidebarLogo(
logoUrl: logoUrl, logoUrl: logoUrl,
height: 44, height: 40,
width: double.infinity, width: double.infinity,
fit: BoxFit.contain, fit: BoxFit.contain,
showBackground: false, showBackground: false,
), ),
), ),
if (widget.onToggleCollapse != null) if (widget.onToggleCollapse != null) ...[
const SizedBox(width: 4),
IconButton( IconButton(
icon: const Icon(Icons.chevron_left, size: 20), icon: const Icon(Icons.chevron_left, size: 20),
tooltip: 'Collapse sidebar', tooltip: 'Collapse sidebar',
@ -323,31 +334,11 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
const BoxConstraints.tightFor(width: 36, height: 36), const BoxConstraints.tightFor(width: 36, height: 36),
onPressed: widget.onToggleCollapse, onPressed: widget.onToggleCollapse,
), ),
],
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 14),
Container( _LogoDivider(theme: theme),
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,
),
),
),
], ],
), ),
); );
@ -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 { class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label}); const _SectionLabel({required this.label});

View File

@ -2,19 +2,86 @@ import 'package:flutter/material.dart';
import '../../core/constants/enums.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 { class AppStatusChip extends StatelessWidget {
const AppStatusChip({ const AppStatusChip({
super.key, super.key,
required this.status, required this.status,
this.compact = false, this.compact = false,
this.forTable = false,
}); });
final String status; final String status;
final bool compact; final bool compact;
final bool forTable;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final (color, label) = _resolveStatus(status); final (color, label) = _resolveStatus(status);
if (forTable) {
return TableStatusBadge(
label: label,
color: color,
compact: compact,
fixedWidth: kTableStatusChipWidth,
);
}
return Chip( return Chip(
label: Text( label: Text(
label, label,

View File

@ -1,5 +1,31 @@
import 'package:flutter/material.dart'; 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). /// Compact outline icon for data-table action columns (matches Users & Roles table).
class AppTableActionIcon extends StatelessWidget { class AppTableActionIcon extends StatelessWidget {
const AppTableActionIcon({ const AppTableActionIcon({
@ -8,43 +34,136 @@ class AppTableActionIcon extends StatelessWidget {
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.color, this.color,
this.enabled = true,
}); });
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final VoidCallback onPressed; final VoidCallback onPressed;
final Color? color; final Color? color;
final bool enabled;
@override @override
Widget build(BuildContext context) { 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( return Tooltip(
message: tooltip, message: tooltip,
child: InkWell( child: _TableActionInkWell(
onTap: onPressed, onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(6),
child: Padding( child: Padding(
padding: const EdgeInsets.all(6), 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. /// Collapsed three-dot trigger that expands inline action icons on hover (or tap).
class AppTableActions extends StatelessWidget { class AppTableActions extends StatefulWidget {
const AppTableActions({super.key, required this.children}); const AppTableActions({
super.key,
required this.children,
this.expandHitArea = true,
});
final List<Widget> children; 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 @override
Widget build(BuildContext context) { 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, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end, 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, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: toolbar, child: toolbar,
), ),
const Divider(height: 1), 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) ...[ if (footer != null) ...[
const Divider(height: 1), const Divider(height: 1),
Padding( Material(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), color: theme.colorScheme.surface,
child: footer!, 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.maxLength,
this.inputFormatters, this.inputFormatters,
this.enabled = true, this.enabled = true,
this.readOnly = false,
this.autofillHints, this.autofillHints,
this.isDense = false, this.isDense = false,
this.autovalidateMode, this.autovalidateMode,
this.fillColor,
}); });
final TextEditingController controller; final TextEditingController controller;
@ -35,9 +37,11 @@ class AppTextField extends StatelessWidget {
final int? maxLength; final int? maxLength;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final bool enabled; final bool enabled;
final bool readOnly;
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
final bool isDense; final bool isDense;
final AutovalidateMode? autovalidateMode; final AutovalidateMode? autovalidateMode;
final Color? fillColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -54,6 +58,7 @@ class AppTextField extends StatelessWidget {
maxLength: maxLength, maxLength: maxLength,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
enabled: enabled, enabled: enabled,
readOnly: readOnly,
autofillHints: autofillHints, autofillHints: autofillHints,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
@ -63,6 +68,9 @@ class AppTextField extends StatelessWidget {
isDense: isDense, isDense: isDense,
floatingLabelBehavior: floatingLabelBehavior:
label != null ? FloatingLabelBehavior.always : null, 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), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
'Add ${_definition.title.toLowerCase()}', 'Add ${_definition.title}',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -330,7 +330,7 @@ class _MasterInlineQuickAddFormState
color: Colors.white, color: Colors.white,
), ),
) )
: const Text('Save'), : Text('Add ${_definition.title}'),
), ),
], ],
), ),

View File

@ -19,7 +19,7 @@ class PageHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 12),
child: context.isMobile child: context.isMobile
? Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -106,6 +106,16 @@ String? resolveSidebarLogoUrl({
return resolveMediaUrl(brandingLogo); 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. /// Resolves sidebar title from company name or app tagline.
String resolveSidebarTitle({ String resolveSidebarTitle({
required String companyName, required String companyName,

View File

@ -43,9 +43,69 @@
<meta name="apple-mobile-web-app-title" content="bharat_erp"> <meta name="apple-mobile-web-app-title" content="bharat_erp">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <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"/> <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> <title>bharat_erp</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>