UI changes review

This commit is contained in:
Surendiran 2026-07-16 15:18:59 +05:30
parent 5a28cfc0da
commit 3914e78465
44 changed files with 1767 additions and 1393 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -0,0 +1,38 @@
// Shared helpers so entity lookup dropdowns omit inactive records.
// Do not use these for status-picker dropdowns (Active / Inactive choices).
const _inactiveStatuses = {
'inactive',
'locked',
'blacklisted',
'disabled',
};
/// Returns true when a raw API row should appear in a lookup dropdown.
bool isActiveOptionRow(Map<String, dynamic> item) {
if (item['is_active'] == false) return false;
if (item['isActive'] == false) return false;
final status = (item['status']?.toString() ?? '').trim().toLowerCase();
if (status.isEmpty) return true;
return !_inactiveStatuses.contains(status);
}
/// Active user suitable for manager / assignee dropdowns.
bool isActiveUserOption({
required String status,
required bool isActive,
}) {
return isActive && status.trim().toLowerCase() == 'active';
}
/// Active vendor suitable for PO / asset vendor dropdowns.
bool isActiveVendorOption({
required bool isActive,
String? status,
}) {
if (!isActive) return false;
final normalized = (status ?? '').trim().toLowerCase();
if (normalized.isEmpty) return true;
return !_inactiveStatuses.contains(normalized);
}

View File

@ -109,7 +109,9 @@ class AssetRemoteDataSource {
ApiEndpoints.itemCategories,
queryParameters: const {'limit': 100, 'is_active': true},
);
return _parseList(response.data, AssetCategoryModel.fromJson);
return _parseList(response.data, AssetCategoryModel.fromJson)
.where((category) => category.isActive)
.toList();
}
Future<AssetDropdownOptionsModel> getAssetOptions() async {

View File

@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/models/grn_model.dart';
import '../../../../shared/models/purchase_order_model.dart';
@ -116,22 +117,41 @@ Future<List<FilterOptionModel>> _safeOptions(
Future<List<FilterOptionModel>> _safeVendorOptions(Ref ref) async {
try {
final result = await ref.read(vendorRepositoryProvider).getVendors(
const VendorListQuery(
page: 1,
limit: AppConstants.maxPageSize,
status: 'active',
),
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.map(
(vendor) => FilterOptionModel(
id: vendor.id,
name: vendor.vendorName,
),
)
.toList();
final vendors = <FilterOptionModel>[];
var page = 1;
var totalPages = 1;
while (page <= totalPages) {
final result = await ref.read(vendorRepositoryProvider).getVendors(
VendorListQuery(
page: page,
limit: AppConstants.maxPageSize,
isActive: true,
),
);
if (result.failure != null || result.data == null) return vendors;
final data = result.data!;
vendors.addAll(
data.items
.where(
(vendor) => isActiveVendorOption(
isActive: vendor.isActive,
status: vendor.status,
),
)
.map(
(vendor) => FilterOptionModel(
id: vendor.id,
name: vendor.vendorName,
),
),
);
totalPages = data.totalPages;
page++;
}
return vendors;
} catch (_) {
return const [];
}
@ -149,10 +169,12 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.where((user) {
final status = user.status.trim().toLowerCase();
return status == 'active' && user.isActive;
})
.where(
(user) => isActiveUserOption(
status: user.status,
isActive: user.isActive,
),
)
.map(
(user) => FilterOptionModel(
id: user.id,

View File

@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
import '../../../../shared/widgets/can_permission.dart';
@ -135,21 +136,15 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
children: [
Padding(
padding: const EdgeInsets.all(16),
child: LayoutBuilder(
builder: (context, constraints) {
return _AssetsFilterBar(
wrapped: constraints.maxWidth < 1000,
query: state.query,
categories: allCategories,
plants: allPlants,
statuses: lookups?.statuses ?? const [],
onSearch: notifier.setSearch,
onCategoryChanged:
notifier.setCategoryFilter,
onPlantChanged: notifier.setPlantFilter,
onStatusChanged: notifier.setStatusFilter,
);
},
child: _AssetsFilterBar(
query: state.query,
categories: allCategories,
plants: allPlants,
statuses: lookups?.statuses ?? const [],
onSearch: notifier.setSearch,
onCategoryChanged: notifier.setCategoryFilter,
onPlantChanged: notifier.setPlantFilter,
onStatusChanged: notifier.setStatusFilter,
),
),
const Divider(height: 1),
@ -275,7 +270,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
class _AssetsFilterBar extends StatelessWidget {
const _AssetsFilterBar({
required this.wrapped,
required this.query,
required this.categories,
required this.plants,
@ -286,7 +280,6 @@ class _AssetsFilterBar extends StatelessWidget {
required this.onStatusChanged,
});
final bool wrapped;
final AssetListQuery query;
final List<AssetCategoryModel> categories;
final List<FilterOptionModel> plants;
@ -315,7 +308,7 @@ class _AssetsFilterBar extends StatelessWidget {
final categoryOptions = <AppDropdownOption<int?>>[
const AppDropdownOption(value: null, label: 'All Categories'),
for (final category in categories)
if (int.tryParse(category.id) != null)
if (category.isActive && int.tryParse(category.id) != null)
AppDropdownOption(
value: int.parse(category.id),
label: category.name,
@ -371,29 +364,9 @@ class _AssetsFilterBar extends StatelessWidget {
),
];
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
const SizedBox(width: 12),
Expanded(child: filters[2]),
],
return AppResponsiveFilterBar(
search: searchField,
filters: filters,
);
}
}

View File

@ -12,6 +12,7 @@ import '../../../../shared/models/user_management_models.dart' show FilterOption
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
@ -880,10 +881,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
subtitle: const Text('Inactive assets are hidden from active lists'),
AppFormToggleField(
label: 'Active',
subtitle: 'Inactive assets are hidden from active lists',
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
),
@ -1079,7 +1079,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
}
Widget _categoryDropdown(List<AssetCategoryModel> categories) {
final categoryIds = categories
final activeCategories =
categories.where((category) => category.isActive).toList();
final categoryIds = activeCategories
.map((c) => int.tryParse(c.id))
.whereType<int>()
.toList();
@ -1089,7 +1091,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
value: _dropdownValue(_categoryId, categoryIds),
searchHint: 'Search category...',
isDense: true,
options: categories
options: activeCategories
.map(
(c) => AppDropdownOption(
value: int.tryParse(c.id) ?? 0,
@ -1105,7 +1107,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
onChanged: (v) => setState(() {
_categoryId = v;
_subcategoryId = null;
final selectedCategory = categories.where((c) => int.tryParse(c.id) == v).firstOrNull;
final selectedCategory =
activeCategories.where((c) => int.tryParse(c.id) == v).firstOrNull;
if (selectedCategory != null) {
if (selectedCategory.defaultDepreciationMethod != null &&
selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) {

View File

@ -11,6 +11,7 @@ import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_date_popup.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
@ -360,9 +361,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
maxLines: 3,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
),
@ -804,9 +804,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
maxLines: 3,
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Under AMC'),
AppFormToggleField(
label: 'Under AMC',
value: _isUnderAmc,
onChanged: (value) => setState(() => _isUnderAmc = value),
),
@ -1151,21 +1150,18 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Auto Renewal'),
AppFormToggleField(
label: 'Auto Renewal',
value: _isAutoRenewal,
onChanged: (value) => setState(() => _isAutoRenewal = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Premium Paid'),
AppFormToggleField(
label: 'Premium Paid',
value: _premiumPaid,
onChanged: (value) => setState(() => _premiumPaid = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (value) => setState(() => _isActive = value),
),

View File

@ -16,6 +16,8 @@ import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_filter_date_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_status_chip.dart';
@ -151,24 +153,19 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
wrapped: constraints.maxWidth < 1100,
onSearch: notifier.setSearch,
onTableChanged: notifier.setTableName,
onActionChanged: notifier.setAction,
onPerformerChanged: notifier.setPerformedBy,
onPickDateRange: () => _pickDateRange(state.query),
onClearDateRange: () => notifier.setDateRange(null, null),
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
);
toolbar: _FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
onSearch: notifier.setSearch,
onTableChanged: notifier.setTableName,
onActionChanged: notifier.setAction,
onPerformerChanged: notifier.setPerformedBy,
onPickDateRange: () => _pickDateRange(state.query),
onClearDateRange: () => notifier.setDateRange(null, null),
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
),
footer: AppPagination(
@ -237,7 +234,6 @@ class _FiltersBar extends StatelessWidget {
required this.searchController,
required this.filters,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onTableChanged,
required this.onActionChanged,
@ -250,7 +246,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController;
final AuditLogFilterOptions filters;
final AuditLogListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onTableChanged;
final ValueChanged<String?> onActionChanged;
@ -270,24 +265,11 @@ class _FiltersBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final searchField = TextField(
final searchField = AppSearchField(
controller: searchController,
hint: 'Search table, action, request ID...',
onChanged: onSearch,
decoration: InputDecoration(
labelText: 'Search',
hintText: 'Search table, action, request ID...',
prefixIcon: const Icon(Icons.search),
isDense: true,
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
onSearch('');
},
)
: null,
),
onClear: () => onSearch(''),
);
final tableDropdown = AppSearchableDropdown<String?>(
@ -350,51 +332,17 @@ class _FiltersBar extends StatelessWidget {
child: const Text('Reset'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
tableDropdown,
const SizedBox(height: 12),
actionDropdown,
const SizedBox(height: 12),
performerDropdown,
const SizedBox(height: 12),
Row(
children: [
Expanded(child: dateField),
resetButton,
],
),
],
);
}
Widget row(List<Widget> cells) {
assert(cells.length == 4);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < cells.length; i++) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(child: cells[i]),
],
],
);
}
return Column(
children: [
row([searchField, tableDropdown, actionDropdown, performerDropdown]),
const SizedBox(height: 12),
row([
dateField,
const SizedBox.shrink(),
const SizedBox.shrink(),
Align(alignment: Alignment.centerRight, child: resetButton),
]),
return AppResponsiveFilterGrid(
fields: [
searchField,
tableDropdown,
actionDropdown,
performerDropdown,
dateField,
Align(
alignment: Alignment.centerRight,
child: resetButton,
),
],
);
}

View File

@ -44,6 +44,9 @@ class _DetailBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasOld = detail.oldValue != null || detail.hasOldValue;
final hasNew = detail.newValue != null || detail.hasNewValue;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -69,20 +72,7 @@ class _DetailBody extends StatelessWidget {
_DetailRow(label: 'Request ID', value: detail.requestId ?? ''),
],
),
if (detail.oldValue != null || detail.hasOldValue)
_ReadableValueSection(
title: 'OLD VALUE',
value: detail.oldValue,
emptyLabel: 'No previous value recorded',
),
if (detail.newValue != null || detail.hasNewValue)
_ReadableValueSection(
title: 'NEW VALUE',
value: detail.newValue,
emptyLabel: 'No new value recorded',
),
if ((detail.oldValue != null || detail.hasOldValue) &&
(detail.newValue != null || detail.hasNewValue))
if (hasOld && hasNew)
_ChangedFieldsSection(
oldValue: detail.oldValue,
newValue: detail.newValue,
@ -135,56 +125,6 @@ class _DetailRow extends StatelessWidget {
}
}
class _ReadableValueSection extends StatelessWidget {
const _ReadableValueSection({
required this.title,
required this.value,
required this.emptyLabel,
});
final String title;
final Map<String, dynamic>? value;
final String emptyLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final rows = value == null ? const <_FieldRow>[] : _flattenFields(value!);
return SidePanelSection(
title: title,
children: [
if (rows.isEmpty)
Text(
emptyLabel,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 14, 14, 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.12),
),
),
child: Column(
children: [
for (final row in rows)
_DetailRow(label: row.label, value: row.value),
],
),
),
],
);
}
}
class _ChangedFieldsSection extends StatelessWidget {
const _ChangedFieldsSection({
required this.oldValue,
@ -238,7 +178,10 @@ class _ChangedFieldsSection extends StatelessWidget {
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 18,
),
child: Icon(
Icons.arrow_forward,
size: 16,
@ -385,7 +328,10 @@ String? _nestedSummary(Map<String, dynamic> nested) {
if (nested.length <= 2) {
return nested.entries
.map((e) => '${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}')
.map(
(e) =>
'${_humanizeKey(e.key.toString())}: ${_formatValue(e.key.toString(), e.value)}',
)
.join(', ');
}

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/purchase_order_model.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
@ -69,10 +70,12 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
);
if (result.failure != null || result.data == null) return const [];
return result.data!.items
.where((user) {
final status = user.status.trim().toLowerCase();
return status == 'active' && user.isActive;
})
.where(
(user) => isActiveUserOption(
status: user.status,
isActive: user.isActive,
),
)
.map(
(user) => FilterOptionModel(
id: user.id,

View File

@ -318,7 +318,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
: const AsyncData<GrnModel?>(null);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
body: lookupsAsync.when(
skipLoadingOnReload: true,
loading: () => lookupsAsync.hasValue
@ -388,236 +388,230 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
const SizedBox(height: 16),
_SectionCard(
title: 'RECEIPT DETAILS',
child: Column(
children: [
FormRowFour(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
const SizedBox(height: 16),
_SectionCard(
title: 'RECEIPT DETAILS',
child: Column(
children: [
_DateField(
label: 'GRN Date *',
value: _grnDate,
enabled: !widget.isEditing,
onTap: widget.isEditing
? null
: () => _pickDate(
current: _grnDate,
onPicked: (d) =>
setState(() => _grnDate = d),
),
),
if (!widget.isEditing)
AppSearchableDropdown<String>(
label: 'Purchase Order *',
value: _selectedPoId,
hint: 'Select PO',
searchHint: 'Search PO...',
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
if (v == null) {
for (final line in _lines) {
line.dispose();
}
setState(() => _lines.clear());
return;
}
try {
final po = await ref.read(
grnPurchaseOrderProvider(v).future,
);
if (mounted && po != null) {
_loadLinesFromPo(po);
}
} catch (e) {
if (!mounted) return;
showAppToastFromSnackBar(context,
SnackBar(content: Text(e.toString())),
);
}
},
validator: (v) => v == null
? 'Purchase Order Is Required'
: null,
)
else
_ReadOnlyField(
label: 'Purchase Order',
value: existing?.poNumber ?? '',
),
MasterQuickAddDropdown<int>(
masterId: 'warehouses',
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _intOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(grnLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: widget.isEditing
? (_) {}
: (v) => setState(() => _warehouseId = v),
validator: widget.isEditing
? null
: (v) =>
v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing,
),
AppTextField(
label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController,
),
],
),
FormRowFour(
children: [
_DateField(
label: 'Vendor Invoice Date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
),
),
AppTextField(
label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}'),
FormRowFour(
children: [
_DateField(
label: 'GRN Date *',
value: _grnDate,
enabled: !widget.isEditing,
onTap: widget.isEditing
? null
: () => _pickDate(
current: _grnDate,
onPicked: (d) =>
setState(() => _grnDate = d),
),
),
if (!widget.isEditing)
AppSearchableDropdown<String>(
label: 'Purchase Order *',
value: _selectedPoId,
hint: 'Select PO',
searchHint: 'Search PO...',
options: poOptions,
onChanged: (v) async {
setState(() => _selectedPoId = v);
if (v == null) {
for (final line in _lines) {
line.dispose();
}
setState(() => _lines.clear());
return;
}
try {
final po = await ref.read(
grnPurchaseOrderProvider(v).future,
);
if (mounted && po != null) {
_loadLinesFromPo(po);
}
} catch (e) {
if (!mounted) return;
showAppToastFromSnackBar(context,
SnackBar(content: Text(e.toString())),
);
}
},
validator: (v) => v == null
? 'Purchase Order Is Required'
: null,
)
else
_ReadOnlyField(
label: 'Purchase Order',
value: existing?.poNumber ?? '',
),
MasterQuickAddDropdown<int>(
masterId: 'warehouses',
label: 'Warehouse *',
value: _dropdownValue(_warehouseId, warehouseIds),
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _intOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(grnLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: widget.isEditing
? (_) {}
: (v) => setState(() => _warehouseId = v),
validator: widget.isEditing
? null
: (v) =>
v == null ? 'Warehouse is required' : null,
enabled: !widget.isEditing,
),
AppTextField(
label: 'Vendor Invoice No',
controller: _vendorInvoiceNoController,
),
],
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Vendor Invoice Amount',
),
),
AppTextField(
label: 'Vehicle No',
controller: _vehicleNoController,
FormRowFour(
children: [
_DateField(
label: 'Vendor Invoice Date',
value: _vendorInvoiceDate,
onTap: () => _pickDate(
current: _vendorInvoiceDate,
onPicked: (d) =>
setState(() => _vendorInvoiceDate = d),
),
),
AppTextField(
label: 'Vendor Invoice Amount',
controller: _vendorInvoiceAmountController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d{0,2}'),
),
],
validator: (v) => Validators.optionalNonNegativeDouble(
v,
fieldName: 'Vendor Invoice Amount',
),
),
AppTextField(
label: 'Vehicle No',
controller: _vehicleNoController,
),
AppTextField(
label: 'LR No',
controller: _lrNoController,
),
],
),
AppTextField(
label: 'LR No',
controller: _lrNoController,
FormRowFour(
children: [
_DateField(
label: 'LR Date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
onPicked: (d) => setState(() => _lrDate = d),
),
),
AppSearchableDropdown<int>(
label: 'Received By',
value: _dropdownValue(
_normalizeUserId(_receivedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality Checked By',
value: _dropdownValue(
_normalizeUserId(_qualityCheckedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
const SizedBox.shrink(),
],
),
],
),
FormRowFour(
children: [
_DateField(
label: 'LR Date',
value: _lrDate,
onTap: () => _pickDate(
current: _lrDate,
onPicked: (d) => setState(() => _lrDate = d),
),
const SizedBox(height: 16),
if (!widget.isEditing)
GrnLineItemsEditor(
items: _lines,
onChanged: () => setState(() {}),
)
else ...[
_SectionCard(
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Line items cannot be changed after posting.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
AppSearchableDropdown<int>(
label: 'Received By',
value: _dropdownValue(
_normalizeUserId(_receivedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _receivedById = v),
),
AppSearchableDropdown<int>(
label: 'Quality Checked By',
value: _dropdownValue(
_normalizeUserId(_qualityCheckedById),
userIds,
),
hint: 'Select user',
searchHint: 'Search user...',
options: _intOptions(lookups.users),
onChanged: (v) =>
setState(() => _qualityCheckedById = v),
),
const SizedBox.shrink(),
],
if (existing?.items.isNotEmpty == true) ...[
const SizedBox(height: 12),
GrnItemsTable(items: existing!.items),
],
],
),
),
],
),
),
const SizedBox(height: 16),
if (!widget.isEditing)
GrnLineItemsEditor(
items: _lines,
onChanged: () => setState(() {}),
)
else ...[
_SectionCard(
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 16),
_SectionCard(
title: 'ADDITIONAL DETAILS',
child: AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this receipt.',
maxLines: 4,
),
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Line items cannot be changed after posting.',
style: theme.textTheme.bodyMedium?.copyWith(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (existing?.items.isNotEmpty == true) ...[
const SizedBox(height: 12),
GrnItemsTable(items: existing!.items),
],
],
),
),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ADDITIONAL DETAILS',
child: AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this receipt.',
maxLines: 4,
),
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${existing?.items.length ?? 0} line item${(existing?.items.length ?? 0) == 1 ? '' : 's'} · editing header only'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
),
),
);
}

View File

@ -15,6 +15,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
@ -83,20 +84,15 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
query: state.query,
wrapped: constraints.maxWidth < 900,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportGrns,
onSearch: ref.read(grnListProvider.notifier).setSearch,
onStatusChanged:
ref.read(grnListProvider.notifier).setStatusFilter,
);
},
toolbar: _FiltersBar(
searchController: _searchController,
query: state.query,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportGrns,
onSearch: ref.read(grnListProvider.notifier).setSearch,
onStatusChanged:
ref.read(grnListProvider.notifier).setStatusFilter,
),
footer: AppPagination(
currentPage: state.query.page,
@ -191,7 +187,6 @@ class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
this.showExport = false,
@ -201,7 +196,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController;
final GrnListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final bool showExport;
@ -212,25 +206,21 @@ class _FiltersBar extends StatelessWidget {
Widget build(BuildContext context) {
final searchField = Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: wrapped ? double.infinity : null,
child: TextField(
controller: searchController,
onChanged: onSearch,
decoration: const InputDecoration(
labelText: 'Search',
hintText: 'Search GRN number, PO, vendor...',
prefixIcon: Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
child: TextField(
controller: searchController,
onChanged: onSearch,
decoration: const InputDecoration(
labelText: 'Search',
hintText: 'Search GRN number, PO, vendor...',
prefixIcon: Icon(Icons.search, size: 20),
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: true,
),
),
);
final statusFilter = SizedBox(
width: wrapped ? double.infinity : 180,
child: AppSearchableDropdown<String?>(
final filters = [
AppSearchableDropdown<String?>(
label: 'Status',
value: query.status,
searchHint: 'Search status...',
@ -243,46 +233,24 @@ class _FiltersBar extends StatelessWidget {
],
onChanged: onStatusChanged,
),
);
];
final exportButton = OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
return AppResponsiveFilterBar(
search: searchField,
filters: filters,
trailing: showExport
? OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
statusFilter,
if (showExport) ...[
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: exportButton),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: statusFilter),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
: null,
);
}
}

View File

@ -664,11 +664,6 @@ class _GrnItemsHeader extends StatelessWidget {
child: Text('CURRENT', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text('RATE', style: style, textAlign: TextAlign.right),
),
const SizedBox(width: 12),
SizedBox(width: 90, child: Text('BATCH', style: style)),
const SizedBox(width: 12),
SizedBox(width: 90, child: Text('MFG', style: style)),
@ -714,11 +709,38 @@ class _GrnItemRow extends StatelessWidget {
),
Expanded(
flex: 3,
child: Text(
item.itemName ?? item.itemCode ?? '',
style: strong,
maxLines: 2,
overflow: TextOverflow.ellipsis,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.itemName ?? item.itemCode ?? '',
style: strong,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (item.remarks?.trim().isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
item.remarks!.trim(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (item.rejectionReason?.trim().isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
'Reject: ${item.rejectionReason!.trim()}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 12),
@ -749,15 +771,6 @@ class _GrnItemRow extends StatelessWidget {
),
),
const SizedBox(width: 12),
SizedBox(
width: 80,
child: Text(
item.rate != null ? _formatQty(item.rate!) : '',
style: body,
textAlign: TextAlign.right,
),
),
const SizedBox(width: 12),
SizedBox(
width: 90,
child: Text(

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../domain/entities/master_definition.dart';
@ -41,6 +42,7 @@ class MasterCrudRemoteDataSource {
int page = 1,
int limit = 20,
String? search,
bool? isActive,
}) async {
final response = await dio.get(
definition.apiPath,
@ -48,6 +50,7 @@ class MasterCrudRemoteDataSource {
'page': page,
'limit': limit,
if (search != null && search.isNotEmpty) 'search': search,
if (isActive != null) 'is_active': isActive,
},
);
@ -85,10 +88,9 @@ class MasterCrudRemoteDataSource {
definition,
page: page,
limit: AppConstants.defaultPageSize,
isActive: true,
);
allItems.addAll(
result.items.where((item) => item['is_active'] != false),
);
allItems.addAll(result.items.where(isActiveOptionRow));
if (page >= result.totalPages) break;
page++;
}

View File

@ -189,18 +189,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return AppSearchExportBar(
wrapped: constraints.maxWidth < 640,
searchController: _searchController,
searchHint: _searchHint(def),
isExporting: state.isExporting,
showExport: canExport,
onSearch: notifier.setSearch,
onExport: _exportRecords,
);
},
toolbar: AppSearchExportBar(
searchController: _searchController,
searchHint: _searchHint(def),
isExporting: state.isExporting,
showExport: canExport,
onSearch: notifier.setSearch,
onExport: _exportRecords,
),
footer: AppPagination(
currentPage: state.page,

View File

@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
@ -112,9 +113,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
switch (field.type) {
case MasterFieldType.boolean:
if (field.key == 'is_active') {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(field.label),
return AppFormToggleField(
label: field.label,
value: value == true,
onChanged: (checked) => notifier.updateValue(field.key, checked),
);

View File

@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/error_view.dart';
@ -103,10 +104,8 @@ class _MasterInlineCreateFormState
switch (field.type) {
case MasterFieldType.boolean:
if (field.key == 'is_active') {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(field.label),
return AppFormToggleField(
label: field.label,
value: value == true,
onChanged: (checked) => notifier.updateValue(field.key, checked),
);
@ -363,20 +362,38 @@ class _MasterInlineCreateFormState
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final quickAddBackground = isDark
? theme.colorScheme.primary.withValues(alpha: 0.16)
: theme.colorScheme.primary.withValues(alpha: 0.08);
final quickAddBorder = isDark
? theme.colorScheme.primary.withValues(alpha: 0.65)
: theme.colorScheme.primary.withValues(alpha: 0.35);
final quickAddGlow = isDark
? theme.colorScheme.primary.withValues(alpha: 0.22)
: theme.colorScheme.primary.withValues(alpha: 0.12);
final formAsync = ref.watch(masterFormProvider(_args));
final def = _definition;
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
color: quickAddBackground,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.none,
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.28),
color: quickAddBorder,
width: 1.2,
),
boxShadow: [
BoxShadow(
color: quickAddGlow,
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
@ -447,29 +464,40 @@ class _MasterInlineCreateFormState
formState: formState,
twoColumns: useTwoColumns,
),
if (active != null) ...[
const SizedBox(height: 4),
_buildField(
field: active,
formState: formState,
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextButton(
onPressed:
isSubmitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
AppButton(
label: 'Add ${def.title}',
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : _submit,
if (active != null)
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: _buildField(
field: active,
formState: formState,
),
),
)
else
const Spacer(),
const SizedBox(width: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed:
isSubmitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
AppButton(
label: 'Add ${def.title}',
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : _submit,
),
],
),
],
),

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -47,7 +49,9 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
final bool enabled;
final bool isDense;
final Map<String, dynamic>? initialValues;
final VoidCallback? refreshLookups;
/// Called after create succeeds; awaited before [onChanged] so callers can
/// reload dependent lookups (e.g. item UOM / GST defaults).
final FutureOr<void> Function()? refreshLookups;
final String? addNewLabel;
@override
@ -114,8 +118,14 @@ class _MasterQuickAddDropdownState<T>
});
}
void _onSaved(String createdId) {
widget.refreshLookups?.call();
Future<void> _onSaved(String createdId) async {
try {
final refresh = widget.refreshLookups;
if (refresh != null) await refresh();
} catch (_) {
// Still select the created row even if lookup refresh fails.
}
if (!mounted) return;
if (createdId != 'created') {
final parsed = widget.parseCreatedId(createdId);
@ -127,7 +137,8 @@ class _MasterQuickAddDropdownState<T>
_collapse();
if (!mounted) return;
showAppToastFromSnackBar(context,
showAppToastFromSnackBar(
context,
SnackBar(
content:
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/user_management_models.dart';
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
@ -65,7 +66,7 @@ class MasterRemoteDataSource {
final options = <FilterOptionModel>[];
for (final item in rows) {
if (item['is_active'] == false) continue;
if (!isActiveOptionRow(item)) continue;
final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue;
@ -104,7 +105,7 @@ class MasterRemoteDataSource {
final pctById = <String, double>{};
for (final item in rows) {
if (item['is_active'] == false) continue;
if (!isActiveOptionRow(item)) continue;
final id = item['id']?.toString() ?? '';
if (id.isEmpty) continue;
@ -135,7 +136,7 @@ class MasterRemoteDataSource {
);
final categoryFilter = itemCategoryId?.toString();
return rows
.where((item) => item['is_active'] != false)
.where(isActiveOptionRow)
.where(
(item) =>
categoryFilter == null ||
@ -157,7 +158,7 @@ class MasterRemoteDataSource {
queryParameters: {'is_active': true},
);
return rows
.where((item) => item['is_active'] != false)
.where(isActiveOptionRow)
.map(
(item) => FilterOptionModel(
id: item['id']?.toString() ?? '',

View File

@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
@ -157,11 +158,12 @@ Future<List<FilterOptionModel>> _fetchActiveVendors(
final data = result.data!;
vendors.addAll(
data.items
.where((vendor) {
if (!vendor.isActive) return false;
final status = (vendor.status ?? '').trim().toLowerCase();
return status != 'blacklisted';
})
.where(
(vendor) => isActiveVendorOption(
isActive: vendor.isActive,
status: vendor.status,
),
)
.map(
(vendor) =>
FilterOptionModel(id: vendor.id, name: vendor.vendorName),

View File

@ -366,243 +366,238 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
return SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
const SizedBox(height: 16),
_SectionCard(
title: 'ORDER DETAILS',
child: Column(
children: [
FormRowFour(
children: [
_DateField(
label: 'PO Date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO Type *',
value: _poType,
hint: 'Select PO type',
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) => AppDropdownOption(
value: e.$1,
label: e.$2,
),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
hint: 'Select vendor',
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
hint: 'Select plant',
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
),
FormRowFour(
children: [
MasterQuickAddDropdown<int?>(
masterId: 'warehouses',
label: 'Warehouse',
value: _warehouseId,
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _warehouseId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'brands',
label: 'Brand',
value: _brandId,
hint: 'Select brand',
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _brandId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'payment_terms',
label: 'Payment Term',
value: _paymentTermId,
hint: 'Select payment term',
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _paymentTermId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'delivery_terms',
label: 'Delivery Term',
value: _deliveryTermId,
hint: 'Select delivery term',
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _deliveryTermId = v),
),
],
),
FormRow(
columnCount: 4,
children: [
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) => setState(
() => _expectedDeliveryDate = d,
),
),
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(existing),
if (_showReapprovalWarning(existing)) ...[
const SizedBox(height: 8),
_ReapprovalBanner(),
],
),
),
const SizedBox(height: 16),
PurchaseOrderLineItemsEditor(
lines: _lines,
items: lookups.items,
itemHsnById: lookups.itemHsnById,
itemUomById: lookups.itemUomById,
itemGstRateById: lookups.itemGstRateById,
uom: lookups.uom,
gstRates: lookups.gstRates,
gstRatePctById: lookups.gstRatePctById,
onAddLine: _addLine,
onRemoveLine: _removeLine,
onChanged: () => setState(() {}),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 900;
final additional = _SectionCard(
title: 'ADDITIONAL DETAILS',
const SizedBox(height: 16),
_SectionCard(
title: 'ORDER DETAILS',
child: Column(
children: [
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
hint: 'Payment terms, inspection conditions, etc.',
maxLines: 5,
FormRowFour(
children: [
_DateField(
label: 'PO Date *',
value: _poDate,
onTap: () => _pickDate(
current: _poDate,
onPicked: (d) => setState(() => _poDate = d),
),
),
AppSearchableDropdown<String>(
label: 'PO Type *',
value: _poType,
hint: 'Select PO type',
searchHint: 'Search type...',
options: poTypeOptions
.map(
(e) => AppDropdownOption(
value: e.$1,
label: e.$2,
),
)
.toList(),
onChanged: (v) => setState(() => _poType = v),
validator: (v) =>
v == null ? 'PO type is required' : null,
),
AppSearchableDropdown<int>(
label: 'Vendor *',
value: _dropdownValue(_vendorId, vendorIds),
hint: 'Select vendor',
searchHint: 'Search vendor...',
options: _intOptions(lookups.vendors),
onChanged: (v) => setState(() => _vendorId = v),
validator: (v) =>
v == null ? 'Vendor is required' : null,
),
MasterQuickAddDropdown<int>(
masterId: 'plants',
label: 'Plant *',
value: _dropdownValue(_plantId, plantIds),
hint: 'Select plant',
searchHint: 'Search plant...',
options: _intOptions(lookups.plants),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _plantId = v),
validator: (v) =>
v == null ? 'Plant is required' : null,
),
],
),
AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this order.',
maxLines: 4,
FormRowFour(
children: [
MasterQuickAddDropdown<int?>(
masterId: 'warehouses',
label: 'Warehouse',
value: _warehouseId,
hint: 'Select warehouse',
searchHint: 'Search warehouse...',
options: _nullableIntOptions(lookups.warehouses),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _warehouseId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'brands',
label: 'Brand',
value: _brandId,
hint: 'Select brand',
searchHint: 'Search brand...',
options: _nullableIntOptions(lookups.brands),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) => setState(() => _brandId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'payment_terms',
label: 'Payment Term',
value: _paymentTermId,
hint: 'Select payment term',
searchHint: 'Search payment term...',
options:
_nullableIntOptions(lookups.paymentTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _paymentTermId = v),
),
MasterQuickAddDropdown<int?>(
masterId: 'delivery_terms',
label: 'Delivery Term',
value: _deliveryTermId,
hint: 'Select delivery term',
searchHint: 'Search delivery term...',
options:
_nullableIntOptions(lookups.deliveryTerms),
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
parseCreatedId: int.tryParse,
onChanged: (v) =>
setState(() => _deliveryTermId = v),
),
],
),
FormRow(
columnCount: 4,
children: [
_DateField(
label: 'Expected Delivery',
value: _expectedDeliveryDate,
onTap: () => _pickDate(
current: _expectedDeliveryDate,
onPicked: (d) => setState(
() => _expectedDeliveryDate = d,
),
),
),
],
),
],
),
);
final summary = _AmountSummaryCard(
totals: totals,
freightController: _freightController,
otherChargesController: _otherChargesController,
discountController: _discountController,
isEditing: widget.isEditing,
);
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
additional,
const SizedBox(height: 16),
summary,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: additional),
const SizedBox(width: 16),
Expanded(flex: 2, child: summary),
],
);
},
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
PurchaseOrderLineItemsEditor(
lines: _lines,
items: lookups.items,
itemHsnById: lookups.itemHsnById,
itemUomById: lookups.itemUomById,
itemGstRateById: lookups.itemGstRateById,
uom: lookups.uom,
gstRates: lookups.gstRates,
gstRatePctById: lookups.gstRatePctById,
onAddLine: _addLine,
onRemoveLine: _removeLine,
onChanged: () => setState(() {}),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
final stack = constraints.maxWidth < 900;
final additional = _SectionCard(
title: 'ADDITIONAL DETAILS',
child: Column(
children: [
AppTextField(
controller: _termsController,
label: 'Terms & Conditions',
hint:
'Payment terms, inspection conditions, etc.',
maxLines: 5,
),
AppTextField(
controller: _remarksController,
label: 'Remarks',
hint: 'Any additional notes for this order.',
maxLines: 4,
),
],
),
);
final summary = _AmountSummaryCard(
totals: totals,
freightController: _freightController,
otherChargesController: _otherChargesController,
discountController: _discountController,
isEditing: widget.isEditing,
);
if (stack) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
additional,
const SizedBox(height: 16),
summary,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: additional),
const SizedBox(width: 16),
Expanded(flex: 2, child: summary),
],
);
},
),
const SizedBox(height: 20),
Row(
children: [
Text(
'Fields marked * are required',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
Text(
widget.isEditing
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
],
),
),
),
),
);
}
@ -718,7 +713,7 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
body: lookupsAsync.when(
loading: () => const AppLoadingView(message: 'Loading form options...'),
error: (e, _) => ErrorView.fromFailure(

View File

@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
@ -103,23 +104,18 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
query: state.query,
statusOptions: _statusFilterOptions(state.orders),
wrapped: constraints.maxWidth < 900,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportPurchaseOrders,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged:
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
onPoTypeChanged:
ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
);
},
toolbar: _FiltersBar(
searchController: _searchController,
query: state.query,
statusOptions: _statusFilterOptions(state.orders),
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportPurchaseOrders,
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
onStatusChanged:
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
onPoTypeChanged:
ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
),
footer: AppPagination(
currentPage: state.query.page,
@ -275,7 +271,6 @@ class _FiltersBar extends StatelessWidget {
required this.searchController,
required this.query,
required this.statusOptions,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
required this.onPoTypeChanged,
@ -287,7 +282,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController;
final PurchaseOrderListQuery query;
final List<AppDropdownOption<String?>> statusOptions;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onPoTypeChanged;
@ -327,48 +321,22 @@ class _FiltersBar extends StatelessWidget {
),
];
final exportButton = OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
return AppResponsiveFilterBar(
search: searchField,
filters: filters,
trailing: showExport
? OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
filters[0],
const SizedBox(height: 12),
filters[1],
if (showExport) ...[
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: exportButton),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
: null,
);
}
}

View File

@ -381,26 +381,78 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
setState(() {});
}
/// Prefer live provider maps so quick-add can autofill after lookups refresh.
Map<String, int?> get _itemUomById =>
ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemUomById ??
widget.itemUomById;
Map<String, int?> get _itemGstRateById =>
ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemGstRateById ??
widget.itemGstRateById;
Map<String, int?> get _itemHsnById =>
ref.read(purchaseOrderLookupsProvider).valueOrNull?.itemHsnById ??
widget.itemHsnById;
bool _fillMissingItemDefaults() {
final itemId = widget.line.itemId;
if (itemId == null) return false;
final key = itemId.toString();
var changed = false;
final defaultUom = _itemUomById[key];
if (widget.line.uomId == null && defaultUom != null) {
widget.line.uomId = defaultUom;
changed = true;
}
final defaultGst = _itemGstRateById[key];
if (widget.line.gstRateId == null && defaultGst != null) {
widget.line.gstRateId = defaultGst;
changed = true;
}
final defaultHsn = _itemHsnById[key];
if (widget.line.hsnCodeId == null && defaultHsn != null) {
widget.line.hsnCodeId = defaultHsn;
changed = true;
}
return changed;
}
void _onItemChanged(int? itemId) {
_updateLine(() {
widget.line.itemId = itemId;
if (itemId == null) return;
final key = itemId.toString();
final defaultUom = widget.itemUomById[key];
final defaultUom = _itemUomById[key];
if (defaultUom != null) {
widget.line.uomId = defaultUom;
}
final defaultGst = widget.itemGstRateById[key];
final defaultGst = _itemGstRateById[key];
if (defaultGst != null) {
widget.line.gstRateId = defaultGst;
}
final defaultHsn = widget.itemHsnById[key];
final defaultHsn = _itemHsnById[key];
if (defaultHsn != null) {
widget.line.hsnCodeId = defaultHsn;
}
});
}
@override
void didUpdateWidget(covariant _LineItemCard oldWidget) {
super.didUpdateWidget(oldWidget);
final mapsChanged = oldWidget.itemUomById != widget.itemUomById ||
oldWidget.itemGstRateById != widget.itemGstRateById ||
oldWidget.itemHsnById != widget.itemHsnById;
if (!mapsChanged) return;
if (!_fillMissingItemDefaults()) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
widget.onChanged();
setState(() {});
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -467,8 +519,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select item',
searchHint: 'Search item name or code...',
options: itemOptions,
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
},
parseCreatedId: int.tryParse,
onChanged: _onItemChanged,
validator: (v) => v == null ? 'Item is required' : null,
@ -500,8 +554,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select UOM',
searchHint: 'Search UOM...',
options: uomOptions,
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
},
parseCreatedId: int.tryParse,
onChanged: (v) => _updateLine(() => line.uomId = v),
validator: (v) => v == null ? 'Required' : null,
@ -543,8 +599,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
hint: 'Select',
searchHint: 'Search GST %...',
options: gstOptions,
refreshLookups: () =>
ref.invalidate(purchaseOrderLookupsProvider),
refreshLookups: () async {
ref.invalidate(purchaseOrderLookupsProvider);
await ref.read(purchaseOrderLookupsProvider.future);
},
parseCreatedId: int.tryParse,
onChanged: (v) => _updateLine(() => line.gstRateId = v),
),

View File

@ -1,5 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/user_management_models.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../../../roles/data/repositories/role_repository_impl.dart';
@ -67,11 +69,21 @@ class AddUserFormNotifier extends FamilyAsyncNotifier<AddUserFormState, String?>
final plants = await masterRemote.listPlants();
final designations = await masterRemote.listDesignations();
final usersResult = await getUsers(const UserListQuery(limit: 100));
final usersResult = await getUsers(
const UserListQuery(
limit: AppConstants.maxPageSize,
status: 'active',
isActive: true,
),
);
if (usersResult.failure != null) throw usersResult.failure!;
final managers = usersResult.data!.items
.where((user) => isReportingManagerRole(user.roleName))
.where(
(user) =>
isReportingManagerRole(user.roleName) &&
isActiveUserOption(status: user.status, isActive: user.isActive),
)
.map((user) => FilterOptionModel(id: user.id, name: user.fullName))
.toList();

View File

@ -13,6 +13,7 @@ import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../users/presentation/widgets/user_rich_data_table.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_data_table.dart';
@ -715,44 +716,38 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
children: [
Padding(
padding: const EdgeInsets.all(16),
child: LayoutBuilder(
builder: (context, constraints) {
final useWrappedFilters = constraints.maxWidth < 1000;
return _UsersFilterBar(
wrapped: useWrappedFilters,
roleFilter: roleFilter,
departmentFilter: departmentFilter,
statusFilter: statusFilter,
roles: roles,
departments: departments,
statuses: statuses,
isExporting: usersState.isExporting,
showExport: canExport,
searchController: _searchController,
onExport: _exportUsers,
onSearch: ref.read(usersListProvider.notifier).setSearch,
onRoleChanged: (value) {
ref.read(usersListProvider.notifier).setRoleFilter(
value == 'All Roles'
? null
: _roleIdForName(value, filters),
);
},
onDepartmentChanged: (value) {
ref.read(usersListProvider.notifier).setDepartmentFilter(
value == 'All Departments'
? null
: _departmentIdForName(value, filters),
);
},
onStatusChanged: (value) {
ref.read(usersListProvider.notifier).setStatusFilter(
value == 'All Statuses'
? null
: _statusValueForLabel(value, filters),
);
},
);
child: _UsersFilterBar(
roleFilter: roleFilter,
departmentFilter: departmentFilter,
statusFilter: statusFilter,
roles: roles,
departments: departments,
statuses: statuses,
isExporting: usersState.isExporting,
showExport: canExport,
searchController: _searchController,
onExport: _exportUsers,
onSearch: ref.read(usersListProvider.notifier).setSearch,
onRoleChanged: (value) {
ref.read(usersListProvider.notifier).setRoleFilter(
value == 'All Roles'
? null
: _roleIdForName(value, filters),
);
},
onDepartmentChanged: (value) {
ref.read(usersListProvider.notifier).setDepartmentFilter(
value == 'All Departments'
? null
: _departmentIdForName(value, filters),
);
},
onStatusChanged: (value) {
ref.read(usersListProvider.notifier).setStatusFilter(
value == 'All Statuses'
? null
: _statusValueForLabel(value, filters),
);
},
),
),
@ -807,7 +802,6 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
class _UsersFilterBar extends StatelessWidget {
const _UsersFilterBar({
required this.wrapped,
required this.roleFilter,
required this.departmentFilter,
required this.statusFilter,
@ -824,7 +818,6 @@ class _UsersFilterBar extends StatelessWidget {
required this.onStatusChanged,
});
final bool wrapped;
final String roleFilter;
final String departmentFilter;
final String statusFilter;
@ -878,56 +871,26 @@ class _UsersFilterBar extends StatelessWidget {
),
];
final exportButton = Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...filters.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
if (showExport) ...[
const SizedBox(height: 12),
exportButton,
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
const SizedBox(width: 12),
Expanded(child: filters[2]),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
return AppResponsiveFilterBar(
search: searchField,
filters: filters,
trailing: showExport
? OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
)
: null,
);
}
}
@ -1314,7 +1277,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Role permission matrix',
'Role Permission Matrix',
style: Theme.of(context)
.textTheme
.titleMedium
@ -1426,7 +1389,7 @@ class _PermissionMatrixTable extends ConsumerWidget {
AppDataColumn(
label: 'Module',
flex: 3,
searchText: (module) => '${module.name} ${module.code}',
enableSearch: false,
cellBuilder: (context, module) {
final index = matrix.modules.indexOf(module);
final appearance =
@ -1465,10 +1428,7 @@ class _PermissionMatrixTable extends ConsumerWidget {
label: permissionActionLabel(action),
flex: 1,
alignment: Alignment.center,
searchText: (module) {
final checked = module.granted[action] ?? false;
return checked ? 'yes granted true' : 'no denied false';
},
enableSearch: false,
cellBuilder: (context, module) {
final checked = module.granted[action] ?? false;
return Checkbox(

View File

@ -1,3 +1,4 @@
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/asset_model.dart';
class ReportFilterOption {
@ -65,6 +66,10 @@ class DepreciationReportFilters {
final raw = json[key];
if (raw is List && raw.isNotEmpty) {
return raw
.where((item) {
if (item is! Map) return true;
return isActiveOptionRow(Map<String, dynamic>.from(item));
})
.map(ReportFilterOption.fromDynamic)
.where((o) => o.value.isNotEmpty)
.toList();

View File

@ -18,10 +18,12 @@ import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_filter_date_field.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_table_shell.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../domain/entities/depreciation_report.dart';
import '../providers/depreciation_report_provider.dart';
import '../../../../shared/widgets/app_toast.dart';
@ -158,7 +160,7 @@ class _DepreciationReportScreenState
)
: const Icon(Icons.download_outlined),
label: Text(
state.isExporting ? 'Exporting...' : 'Export CSV',
state.isExporting ? 'Exporting...' : 'Export',
),
),
],
@ -167,32 +169,27 @@ class _DepreciationReportScreenState
const SizedBox(height: 12),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
wrapped: constraints.maxWidth < 1100,
onSearch: notifier.setSearch,
onPlantChanged: notifier.setPlantId,
onCategoryChanged: notifier.setItemCategoryId,
onSubcategoryChanged: notifier.setItemSubcategoryId,
onDepartmentChanged: notifier.setDepartmentId,
onStatusChanged: notifier.setStatus,
onMethodChanged: notifier.setDepreciationMethod,
onIsActiveChanged: notifier.setIsActive,
onPickAsOfDate: () => _pickAsOfDate(state.query),
onClearAsOfDate: () => notifier.setAsOfDate(null),
onPickPurchaseRange: () =>
_pickPurchaseDateRange(state.query),
onClearPurchaseRange: () =>
notifier.setPurchaseDateRange(null, null),
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
);
toolbar: _FiltersBar(
searchController: _searchController,
filters: state.filters,
query: state.query,
onSearch: notifier.setSearch,
onPlantChanged: notifier.setPlantId,
onCategoryChanged: notifier.setItemCategoryId,
onSubcategoryChanged: notifier.setItemSubcategoryId,
onDepartmentChanged: notifier.setDepartmentId,
onStatusChanged: notifier.setStatus,
onMethodChanged: notifier.setDepreciationMethod,
onIsActiveChanged: notifier.setIsActive,
onPickAsOfDate: () => _pickAsOfDate(state.query),
onClearAsOfDate: () => notifier.setAsOfDate(null),
onPickPurchaseRange: () =>
_pickPurchaseDateRange(state.query),
onClearPurchaseRange: () =>
notifier.setPurchaseDateRange(null, null),
onReset: () {
_searchController.clear();
notifier.resetFilters();
},
),
footer: AppPagination(
@ -326,7 +323,6 @@ class _FiltersBar extends StatefulWidget {
required this.searchController,
required this.filters,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onPlantChanged,
required this.onCategoryChanged,
@ -345,7 +341,6 @@ class _FiltersBar extends StatefulWidget {
final TextEditingController searchController;
final DepreciationReportFilters filters;
final DepreciationReportQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onPlantChanged;
final ValueChanged<String?> onCategoryChanged;
@ -399,24 +394,11 @@ class _FiltersBarState extends State<_FiltersBar> {
.where((o) => o.parentId == query.itemCategoryId)
.toList();
final searchField = TextField(
final searchField = AppSearchField(
controller: widget.searchController,
hint: 'Search asset code or name...',
onChanged: widget.onSearch,
decoration: InputDecoration(
labelText: 'Search',
hintText: 'Search asset code or name...',
prefixIcon: const Icon(Icons.search),
isDense: true,
suffixIcon: widget.searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
widget.searchController.clear();
widget.onSearch('');
},
)
: null,
),
onClear: () => widget.onSearch(''),
);
Widget dropdown({
@ -536,19 +518,6 @@ class _FiltersBarState extends State<_FiltersBar> {
child: const Text('Reset'),
);
Widget row(List<Widget> cells) {
assert(cells.length == 4);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < cells.length; i++) ...[
if (i > 0) const SizedBox(width: 12),
Expanded(child: cells[i]),
],
],
);
}
final actions = Row(
children: [
moreButton,
@ -556,58 +525,36 @@ class _FiltersBarState extends State<_FiltersBar> {
],
);
if (widget.wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
plant,
const SizedBox(height: 12),
category,
const SizedBox(height: 12),
asOfField,
const SizedBox(height: 4),
actions,
if (_moreOpen) ...[
const SizedBox(height: 8),
Divider(color: theme.colorScheme.outline.withValues(alpha: 0.2)),
const SizedBox(height: 8),
subcategory,
const SizedBox(height: 12),
department,
const SizedBox(height: 12),
status,
const SizedBox(height: 12),
method,
const SizedBox(height: 12),
active,
const SizedBox(height: 12),
purchaseField,
],
],
);
}
final moreFilters = _moreOpen
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Divider(
height: 1,
color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
const SizedBox(height: 12),
AppResponsiveFilterGrid(
fields: [
subcategory,
department,
status,
method,
active,
purchaseField,
],
),
],
)
: null;
return Column(
children: [
row([searchField, plant, category, asOfField]),
const SizedBox(height: 4),
Align(alignment: Alignment.centerRight, child: actions),
if (_moreOpen) ...[
const SizedBox(height: 8),
Divider(height: 1, color: theme.colorScheme.outline.withValues(alpha: 0.2)),
const SizedBox(height: 12),
row([subcategory, department, status, method]),
const SizedBox(height: 12),
row([
active,
purchaseField,
const SizedBox.shrink(),
const SizedBox.shrink(),
]),
],
],
return AppResponsiveFilterGrid(
fields: [searchField, plant, category, asOfField],
footer: Align(
alignment: Alignment.centerRight,
child: actions,
),
extra: moreFilters,
);
}
}
@ -664,26 +611,6 @@ class _ReportTable extends StatelessWidget {
textAlign: TextAlign.right,
),
),
AppDataColumn(
label: 'Method',
flex: 2,
searchText: (row) => row.depreciationMethod ?? '',
cellBuilder: (_, row) => AppTableCell.text(
row.depreciationMethod,
placeholder: '-',
),
),
AppDataColumn(
label: 'Rate %',
flex: 1,
alignment: Alignment.centerRight,
searchText: (row) => row.depreciationRate?.toStringAsFixed(2) ?? '',
cellBuilder: (_, row) => AppTableCell.text(
row.depreciationRate?.toStringAsFixed(2),
textAlign: TextAlign.right,
placeholder: '-',
),
),
AppDataColumn(
label: 'Annual',
flex: 2,

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/permission_matrix_models.dart';
import '../../../../shared/models/role_model.dart';
import '../../../../shared/models/user_management_models.dart';
@ -42,13 +43,25 @@ class RoleRemoteDataSource {
if (search != null && search.isNotEmpty) 'search': search,
},
);
return _parseList(
response.data['data'],
(json) => FilterOptionModel(
id: json['id']?.toString() ?? '',
name: json['name'] as String? ?? '',
),
).where((role) => role.id.isNotEmpty && role.name.isNotEmpty).toList();
final raw = response.data['data'];
final list = raw is List
? raw
: raw is Map
? (raw['items'] as List?) ?? const []
: const [];
return list
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.where(isActiveOptionRow)
.map(
(json) => FilterOptionModel(
id: json['id']?.toString() ?? '',
name: json['name'] as String? ?? '',
),
)
.where((role) => role.id.isNotEmpty && role.name.isNotEmpty)
.toList();
}
Future<RoleModel> getRoleById(String id) async {

View File

@ -91,7 +91,7 @@ class _MatrixGrid extends ConsumerWidget {
AppDataColumn(
label: 'Module',
flex: 3,
searchText: (row) => row.name,
enableSearch: false,
cellBuilder: (_, row) => AppTableCell.text(row.name),
),
...matrix.actionColumns.map(
@ -99,10 +99,7 @@ class _MatrixGrid extends ConsumerWidget {
label: permissionActionLabel(action),
flex: 1,
alignment: Alignment.center,
searchText: (row) {
final checked = row.granted[action] ?? false;
return checked ? 'yes granted true' : 'no denied false';
},
enableSearch: false,
cellBuilder: (_, row) => Checkbox(
value: row.granted[action] ?? false,
visualDensity: VisualDensity.compact,

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../domain/entities/app_settings.dart';
@ -117,10 +118,9 @@ class SettingsSwitchTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
return AppFormToggleField(
label: title,
subtitle: subtitle,
value: value,
onChanged: onChanged,
);

View File

@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/utils/active_option.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/export_file_result.dart';
import '../../../../shared/models/user_management_models.dart';
@ -31,9 +32,11 @@ class UserRemoteDataSource {
List<FilterOptionModel> parseIdNameOptions(List<dynamic>? list) {
return (list ?? [])
.map(
(item) => FilterOptionModel.fromJson(item as Map<String, dynamic>),
)
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.where(isActiveOptionRow)
.map(FilterOptionModel.fromJson)
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
.toList();
}

View File

@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_status_chip.dart';
@ -272,29 +273,9 @@ class _FiltersBar extends StatelessWidget {
),
];
if (context.isMobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
...dropdowns.expand((f) => [f, const SizedBox(height: 12)]).toList()
..removeLast(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[1]),
const SizedBox(width: 12),
Expanded(flex: 2, child: dropdowns[2]),
],
return AppResponsiveFilterBar(
search: searchField,
filters: dropdowns,
);
}
}

View File

@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
import '../../../../shared/widgets/app_search_field.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/app_table_action_icon.dart';
@ -90,22 +91,17 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
),
Expanded(
child: AppTableShell(
toolbar: LayoutBuilder(
builder: (context, constraints) {
return _FiltersBar(
searchController: _searchController,
query: state.query,
wrapped: constraints.maxWidth < 900,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportVendors,
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
onStatusChanged:
ref.read(vendorsListProvider.notifier).setStatusFilter,
onVendorTypeChanged:
ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
);
},
toolbar: _FiltersBar(
searchController: _searchController,
query: state.query,
showExport: canExport,
isExporting: state.isExporting,
onExport: _exportVendors,
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
onStatusChanged:
ref.read(vendorsListProvider.notifier).setStatusFilter,
onVendorTypeChanged:
ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
),
footer: AppPagination(
currentPage: state.query.page,
@ -209,7 +205,6 @@ class _FiltersBar extends StatelessWidget {
const _FiltersBar({
required this.searchController,
required this.query,
required this.wrapped,
required this.onSearch,
required this.onStatusChanged,
required this.onVendorTypeChanged,
@ -220,7 +215,6 @@ class _FiltersBar extends StatelessWidget {
final TextEditingController searchController;
final VendorListQuery query;
final bool wrapped;
final ValueChanged<String> onSearch;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onVendorTypeChanged;
@ -265,48 +259,22 @@ class _FiltersBar extends StatelessWidget {
),
];
final exportButton = OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
return AppResponsiveFilterBar(
search: searchField,
filters: filters,
trailing: showExport
? OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
filters[0],
const SizedBox(height: 12),
filters[1],
if (showExport) ...[
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: exportButton),
],
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
if (showExport) ...[
const SizedBox(width: 12),
exportButton,
],
],
: null,
);
}
}

View File

@ -7,6 +7,7 @@ import '../../../../shared/models/user_management_models.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
@ -329,9 +330,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
),
if (_showActiveSwitch) ...[
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),

View File

@ -5,6 +5,7 @@ import '../../../../core/utils/validators.dart';
import '../../../../shared/models/vendor_model.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_form_toggle_field.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../providers/vendors_provider.dart';
@ -196,9 +197,8 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
inputFormatters: Validators.gstinInput,
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
@ -325,15 +325,13 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Primary Contact'),
AppFormToggleField(
label: 'Primary Contact',
value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),
@ -491,15 +489,13 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
onChanged: (v) => setState(() => _accountType = v),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Primary Account'),
AppFormToggleField(
label: 'Primary Account',
value: _isPrimary,
onChanged: (v) => setState(() => _isPrimary = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Active'),
AppFormToggleField(
label: 'Active',
value: _isActive,
onChanged: (v) => setState(() => _isActive = v),
),

View File

@ -42,12 +42,6 @@ const List<MenuItem> appMenuItems = [
// route: RouteConstants.branches,
// module: 'branches',
// ),
MenuItem(
label: 'Users & Roles',
icon: Icons.admin_panel_settings_outlined,
route: RouteConstants.usersRoleManagement,
module: 'users',
),
MenuItem(
label: 'Assets',
icon: Icons.inventory_2_outlined,
@ -86,18 +80,24 @@ const List<MenuItem> appMenuItems = [
route: RouteConstants.grn,
module: 'grn',
),
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,
route: RouteConstants.masterData,
module: 'master_data',
),
MenuItem(
label: 'Reports',
icon: Icons.assessment_outlined,
route: RouteConstants.reports,
module: 'reports',
),
MenuItem(
label: 'Users & Roles',
icon: Icons.admin_panel_settings_outlined,
route: RouteConstants.usersRoleManagement,
module: 'users',
),
MenuItem(
label: 'Master Data',
icon: Icons.dataset_outlined,
route: RouteConstants.masterData,
module: 'master_data',
),
MenuItem(
label: 'Audit Logs',
icon: Icons.history_outlined,
@ -112,6 +112,13 @@ const List<MenuItem> appMenuItems = [
),
];
/// Routes rendered under the Support section (after primary ops menus).
const Set<String> supportMenuRoutes = {
RouteConstants.usersRoleManagement,
RouteConstants.masterData,
RouteConstants.auditLogs,
RouteConstants.settings,
};
List<MenuItem> getVisibleMenuItems({
required List<String> permissions,
required UserRole role,

View File

@ -13,6 +13,7 @@ class AppFilterDateField extends StatelessWidget {
this.placeholder = 'Select date',
this.onClear,
this.isEmpty = false,
this.alignWithLabeledFields = true,
});
final String label;
@ -22,6 +23,7 @@ class AppFilterDateField extends StatelessWidget {
final String placeholder;
final VoidCallback? onClear;
final bool isEmpty;
final bool alignWithLabeledFields;
static const _suffixConstraints = BoxConstraints(
minWidth: 40,
@ -35,7 +37,7 @@ class AppFilterDateField extends StatelessWidget {
final theme = Theme.of(context);
final showClear = onClear != null && !isEmpty;
return InputDecorator(
final field = InputDecorator(
decoration: InputDecoration(
labelText: label,
isDense: true,
@ -79,5 +81,11 @@ class AppFilterDateField extends StatelessWidget {
),
),
);
if (!alignWithLabeledFields) return field;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: field,
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// Compact, theme-aware toggle for forms.
///
/// Keeps the switch close to the label for better visual grouping.
class AppFormToggleField extends StatelessWidget {
const AppFormToggleField({
super.key,
required this.label,
required this.value,
required this.onChanged,
this.subtitle,
this.enabled = true,
});
final String label;
final String? subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
color: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 10),
Transform.scale(
scale: 0.80,
child: Switch(
value: value,
onChanged: enabled ? onChanged : null,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
if (subtitle != null && subtitle!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import '../../core/utils/responsive_utils.dart';
/// Responsive list-page filter layout.
///
/// - Wide: search + filters (+ trailing) in one row
/// - Medium: search full width, filters wrap 2+ per row
/// - Narrow: one field per row
class AppResponsiveFilterBar extends StatelessWidget {
const AppResponsiveFilterBar({
super.key,
required this.search,
this.filters = const [],
this.trailing,
this.spacing = 12,
this.runSpacing = 12,
this.searchFlex = 3,
this.filterFlex = 2,
this.crossAxisAlignment = CrossAxisAlignment.start,
});
final Widget search;
final List<Widget> filters;
final Widget? trailing;
final double spacing;
final double runSpacing;
final int searchFlex;
final int filterFlex;
final CrossAxisAlignment crossAxisAlignment;
static const double _rowBreakpoint = 720;
static const double _stackBreakpoint = AppBreakpoints.tablet; // 600
static const double _minFilterWidth = 160;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final hasTrailing = trailing != null;
if (maxWidth >= _rowBreakpoint) {
return Row(
crossAxisAlignment: crossAxisAlignment,
children: [
Expanded(flex: searchFlex, child: search),
for (final filter in filters) ...[
SizedBox(width: spacing),
Expanded(flex: filterFlex, child: filter),
],
if (hasTrailing) ...[
SizedBox(width: spacing),
Padding(
padding: const EdgeInsets.only(top: 8),
child: trailing!,
),
],
],
);
}
if (maxWidth >= _stackBreakpoint) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
search,
SizedBox(height: runSpacing),
Wrap(
spacing: spacing,
runSpacing: runSpacing,
children: [
for (final filter in filters)
SizedBox(
width: _wrapChildWidth(
maxWidth: maxWidth,
itemCount: filters.length + (hasTrailing ? 1 : 0),
),
child: filter,
),
if (hasTrailing)
SizedBox(
width: _wrapChildWidth(
maxWidth: maxWidth,
itemCount: filters.length + 1,
),
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: trailing!,
),
),
),
],
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
search,
for (final filter in filters) ...[
SizedBox(height: runSpacing),
filter,
],
if (hasTrailing) ...[
SizedBox(height: runSpacing),
Align(alignment: Alignment.centerRight, child: trailing!),
],
],
);
},
);
}
double _wrapChildWidth({
required double maxWidth,
required int itemCount,
}) {
if (itemCount <= 0) return maxWidth;
final preferredCols = maxWidth >= 840 ? 3 : 2;
final cols = preferredCols.clamp(1, itemCount);
final gaps = spacing * (cols - 1);
final width = (maxWidth - gaps) / cols;
return width < _minFilterWidth ? maxWidth : width;
}
}
/// Responsive grid for screens with many filter fields (audit, reports, etc.).
///
/// - Wide: up to [maxColumns] fields per row
/// - Medium: wrapped 23 fields per row
/// - Narrow: one field per row
class AppResponsiveFilterGrid extends StatelessWidget {
const AppResponsiveFilterGrid({
super.key,
required this.fields,
this.spacing = 12,
this.runSpacing = 12,
this.minFieldWidth = 160,
this.maxColumns = 4,
this.footer,
this.extra,
});
final List<Widget> fields;
final double spacing;
final double runSpacing;
final double minFieldWidth;
final int maxColumns;
final Widget? footer;
final Widget? extra;
static const double _rowBreakpoint = 720;
static const double _stackBreakpoint = AppBreakpoints.tablet;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
Widget content;
if (maxWidth >= _rowBreakpoint && fields.length <= maxColumns) {
content = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) SizedBox(width: spacing),
Expanded(child: fields[i]),
],
],
);
} else if (maxWidth >= _stackBreakpoint) {
final cols = maxWidth >= 900
? 3
: maxWidth >= _rowBreakpoint
? 2
: 2;
final itemWidth = _itemWidth(maxWidth, cols);
content = Wrap(
spacing: spacing,
runSpacing: runSpacing,
children: [
for (final field in fields)
SizedBox(width: itemWidth, child: field),
],
);
} else {
content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) SizedBox(height: runSpacing),
fields[i],
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
content,
if (footer != null) ...[
SizedBox(height: runSpacing),
footer!,
],
if (extra != null) ...[
SizedBox(height: runSpacing),
extra!,
],
],
);
},
);
}
double _itemWidth(double maxWidth, int cols) {
final gaps = spacing * (cols - 1);
final width = (maxWidth - gaps) / cols;
return width < minFieldWidth ? maxWidth : width;
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'app_responsive_filter_bar.dart';
/// Search field with export action matches the users list toolbar (no filters).
class AppSearchExportBar extends StatelessWidget {
const AppSearchExportBar({
@ -8,6 +10,7 @@ class AppSearchExportBar extends StatelessWidget {
required this.onSearch,
required this.onExport,
this.isExporting = false,
@Deprecated('Layout is always responsive via AppResponsiveFilterBar')
this.wrapped = false,
this.searchController,
this.searchWidth = 320,
@ -35,42 +38,21 @@ class AppSearchExportBar extends StatelessWidget {
onChanged: onSearch,
);
final exportButton = OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
return AppResponsiveFilterBar(
search: searchField,
trailing: showExport
? OutlinedButton.icon(
onPressed: isExporting ? null : onExport,
icon: isExporting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
)
: const Icon(Icons.download_outlined, size: 18),
label: Text(isExporting ? 'Exporting...' : 'Export'),
);
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
if (showExport) ...[
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
exportButton,
],
),
],
],
);
}
return Row(
children: [
SizedBox(width: searchWidth, child: searchField),
const Spacer(),
if (showExport) exportButton,
],
: null,
);
}
}

View File

@ -230,6 +230,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
decoration: InputDecoration(
labelText: widget.label,
hintText: displayLabel == null ? effectiveHint : null,
hintMaxLines: 1,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense,
errorText: field.errorText,
@ -612,6 +613,7 @@ class _AppSearchableLookupFieldState<T>
decoration: InputDecoration(
labelText: widget.label,
hintText: displayLabel == null ? effectiveHint : null,
hintMaxLines: 1,
floatingLabelBehavior: FloatingLabelBehavior.always,
isDense: widget.isDense,
suffixIcon: Icon(

View File

@ -57,8 +57,16 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
final Set<String> _expandedMenus = <String>{};
final Set<String> _manuallyCollapsedMenus = <String>{};
List<menu.MenuItem> get _mainMenuItems =>
widget.menuItems.where((item) => item.route != RouteConstants.settings).toList();
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
.where((item) => !menu.supportMenuRoutes.contains(item.route))
.toList();
List<menu.MenuItem> get _supportMenuItems => widget.menuItems
.where((item) => menu.supportMenuRoutes.contains(item.route))
.toList();
bool get _hasSupportSection =>
_supportMenuItems.isNotEmpty || AppConstants.showNotificationsMenu;
bool _routeMatches(String route) {
final current = widget.currentRoute;
@ -104,7 +112,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
void didUpdateWidget(covariant AppSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentRoute != widget.currentRoute) {
for (final item in _mainMenuItems) {
for (final item in [..._primaryMenuItems, ..._supportMenuItems]) {
if (item.children.isNotEmpty && _isGroupActive(item)) {
_expandedMenus.add(item.route);
_manuallyCollapsedMenus.remove(item.route);
@ -167,84 +175,25 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isNarrow)
const _SectionLabel(label: 'MAIN MENU')
else
const SizedBox(height: 4),
..._mainMenuItems.map((item) {
if (isNarrow && item.children.isNotEmpty) {
return _CollapsedFlyoutNavItem(
item: item,
selected: _isGroupActive(item),
isChildSelected: (route) =>
_isSelectedAmongSiblings(route, item.children),
onChildTap: widget.onItemTap,
);
}
if (item.children.isNotEmpty && !isNarrow) {
final expanded = _isGroupExpanded(item);
final active = _isGroupActive(item);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
),
if (expanded)
...item.children.map(
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
),
],
);
}
return _SidebarNavItem(
icon: item.icon,
label: item.label,
selected: _isSelected(item.route) ||
(isNarrow && _isGroupActive(item)),
collapsed: isNarrow,
onTap: () {
widget.onItemTap(item.route);
},
);
}),
const SizedBox(height: 16),
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
if (AppConstants.showNotificationsMenu)
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
if (_hasSettings)
_SidebarNavItem(
icon: Icons.settings_outlined,
label: 'Settings',
selected: _isSelected(RouteConstants.settings),
collapsed: isNarrow,
onTap: () => widget.onItemTap(RouteConstants.settings),
if (isNarrow) const SizedBox(height: 4),
..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow),
if (_hasSupportSection) ...[
const SizedBox(height: 16),
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
if (AppConstants.showNotificationsMenu)
_SidebarNavItem(
icon: Icons.notifications_outlined,
label: 'Notifications',
selected: false,
collapsed: isNarrow,
badge: isNarrow ? null : '3',
onTap: () {},
),
..._buildNavItems(
_supportMenuItems,
isNarrow: isNarrow,
),
],
],
),
),
@ -259,8 +208,66 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
);
}
bool get _hasSettings =>
widget.menuItems.any((item) => item.route == RouteConstants.settings);
List<Widget> _buildNavItems(
List<menu.MenuItem> items, {
required bool isNarrow,
}) {
return items.map((item) {
if (isNarrow && item.children.isNotEmpty) {
return _CollapsedFlyoutNavItem(
item: item,
selected: _isGroupActive(item),
isChildSelected: (route) =>
_isSelectedAmongSiblings(route, item.children),
onChildTap: widget.onItemTap,
);
}
if (item.children.isNotEmpty && !isNarrow) {
final expanded = _isGroupExpanded(item);
final active = _isGroupActive(item);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_SidebarNavItem(
icon: item.icon,
label: item.label,
selected: active,
collapsed: false,
showChevron: true,
chevronExpanded: expanded,
onTap: () => _toggleGroup(item),
),
if (expanded)
...item.children.map(
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),
),
),
],
);
}
return _SidebarNavItem(
icon: item.icon,
label: item.label,
selected: _isSelected(item.route) ||
(isNarrow && _isGroupActive(item)),
collapsed: isNarrow,
onTap: () {
widget.onItemTap(item.route);
},
);
}).toList();
}
Widget _buildHeader(BuildContext context, {required bool isNarrow}) {
final theme = Theme.of(context);

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/validators.dart';
import '../../modules/master_data/data/repositories/master_repository_impl.dart';
import '../../modules/master_data/domain/entities/master_definition.dart';
import 'app_form_toggle_field.dart';
/// Compact master create form for use inside a dropdown panel.
class MasterInlineQuickAddForm extends ConsumerStatefulWidget {
@ -176,10 +177,8 @@ class _MasterInlineQuickAddFormState
switch (field.type) {
case MasterFieldType.boolean:
if (field.key == 'is_active') {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(field.label, style: const TextStyle(fontSize: 13)),
return AppFormToggleField(
label: field.label,
value: _values[field.key] == true,
onChanged: _submitting
? null
@ -253,6 +252,9 @@ class _MasterInlineQuickAddFormState
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fields = _definition.formFields;
final activeField = fields.where((field) => field.key == 'is_active').firstOrNull;
final regularFields =
fields.where((field) => field.key != 'is_active').toList();
return Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
@ -297,7 +299,7 @@ class _MasterInlineQuickAddFormState
),
)
else ...[
for (final field in fields) ...[
for (final field in regularFields) ...[
_buildField(field),
const SizedBox(height: 8),
],
@ -312,25 +314,40 @@ class _MasterInlineQuickAddFormState
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(),
TextButton(
onPressed: _submitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('Add ${_definition.title}'),
if (activeField != null)
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: _buildField(activeField),
),
)
else
const Spacer(),
const SizedBox(width: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: _submitting ? null : widget.onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('Add ${_definition.title}'),
),
],
),
],
),

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
@ -31,6 +34,7 @@ class SidebarLogo extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isLight = theme.brightness == Brightness.light;
final fallback = Image.asset(
AppConstants.defaultLogoAsset,
width: _width,
@ -43,7 +47,7 @@ class SidebarLogo extends StatelessWidget {
),
);
final content = _buildLogoContent(fallback);
final content = _buildLogoContent(fallback, knockOutBlack: isLight);
if (!showBackground) {
return SizedBox(width: _width, height: _height, child: content);
@ -61,39 +65,50 @@ class SidebarLogo extends StatelessWidget {
);
}
Widget _buildLogoContent(Widget fallback) {
Widget _buildLogoContent(Widget fallback, {required bool knockOutBlack}) {
final url = resolveMediaUrl(logoUrl);
if (url == null || url.isEmpty) {
return fallback;
return _maybeKnockOut(fallbackProvider: const AssetImage(AppConstants.defaultLogoAsset), fallback: fallback, knockOutBlack: knockOutBlack);
}
if (url.startsWith('assets/')) {
return Image.asset(
final image = Image.asset(
url,
width: _width,
height: _height,
fit: fit,
errorBuilder: (_, __, ___) => fallback,
);
return _maybeKnockOut(
fallbackProvider: AssetImage(url),
fallback: image,
knockOutBlack: knockOutBlack,
);
}
if (url.startsWith('data:image')) {
try {
final base64Str = url.contains(',') ? url.split(',').last : url;
return Image.memory(
base64Decode(base64Str),
final bytes = base64Decode(base64Str);
final image = Image.memory(
bytes,
width: _width,
height: _height,
fit: fit,
errorBuilder: (_, __, ___) => fallback,
);
return _maybeKnockOut(
fallbackProvider: MemoryImage(bytes),
fallback: image,
knockOutBlack: knockOutBlack,
);
} catch (_) {
return fallback;
}
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return CachedNetworkImage(
final image = CachedNetworkImage(
imageUrl: url,
width: _width,
height: _height,
@ -107,10 +122,224 @@ class SidebarLogo extends StatelessWidget {
),
errorWidget: (_, __, ___) => fallback,
);
return _maybeKnockOut(
fallbackProvider: CachedNetworkImageProvider(url),
fallback: image,
knockOutBlack: knockOutBlack,
);
}
return fallback;
}
Widget _maybeKnockOut({
required ImageProvider fallbackProvider,
required Widget fallback,
required bool knockOutBlack,
}) {
if (!knockOutBlack) return fallback;
return _BlackBackgroundKnockOut(
provider: fallbackProvider,
width: _width,
height: _height,
fit: fit,
fallback: fallback,
);
}
}
/// Removes a solid black logo backdrop in light mode when corners are black.
class _BlackBackgroundKnockOut extends StatefulWidget {
const _BlackBackgroundKnockOut({
required this.provider,
required this.width,
required this.height,
required this.fit,
required this.fallback,
});
final ImageProvider provider;
final double width;
final double height;
final BoxFit fit;
final Widget fallback;
@override
State<_BlackBackgroundKnockOut> createState() =>
_BlackBackgroundKnockOutState();
}
class _BlackBackgroundKnockOutState extends State<_BlackBackgroundKnockOut> {
static final Map<String, ui.Image> _cache = {};
ui.Image? _processed;
bool _failed = false;
Object? _providerKey;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(covariant _BlackBackgroundKnockOut oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.provider != widget.provider) {
_load();
}
}
Future<void> _load() async {
final key = _cacheKey(widget.provider);
_providerKey = key;
final cached = _cache[key];
if (cached != null) {
if (mounted) {
setState(() {
_processed = cached;
_failed = false;
});
}
return;
}
try {
final source = await _resolveImage(widget.provider);
final processed = await _knockOutBlackBackground(source);
source.dispose();
_cache[key] = processed;
if (!mounted || _providerKey != key) {
// Another load superseded this one; keep cache entry.
return;
}
setState(() {
_processed = processed;
_failed = false;
});
} catch (_) {
if (mounted && _providerKey == key) {
setState(() {
_processed = null;
_failed = true;
});
}
}
}
String _cacheKey(ImageProvider provider) {
if (provider is AssetImage) return 'asset:${provider.assetName}';
if (provider is MemoryImage) {
return 'memory:${provider.bytes.length}:${Object.hashAll(provider.bytes.take(64))}';
}
if (provider is NetworkImage) return 'net:${provider.url}';
if (provider is CachedNetworkImageProvider) return 'cnet:${provider.url}';
return 'other:${provider.runtimeType}:$provider';
}
Future<ui.Image> _resolveImage(ImageProvider provider) async {
final completer = Completer<ui.Image>();
final stream = provider.resolve(const ImageConfiguration());
late final ImageStreamListener listener;
listener = ImageStreamListener(
(info, _) {
stream.removeListener(listener);
completer.complete(info.image.clone());
},
onError: (error, stack) {
stream.removeListener(listener);
completer.completeError(error, stack);
},
);
stream.addListener(listener);
return completer.future;
}
Future<ui.Image> _knockOutBlackBackground(ui.Image source) async {
final byteData =
await source.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData == null) return source.clone();
final pixels = byteData.buffer.asUint8List();
final width = source.width;
final height = source.height;
if (!_hasBlackBackdrop(pixels, width, height)) {
return source.clone();
}
const threshold = 28;
for (var i = 0; i < pixels.length; i += 4) {
final r = pixels[i];
final g = pixels[i + 1];
final b = pixels[i + 2];
if (r <= threshold && g <= threshold && b <= threshold) {
pixels[i + 3] = 0;
}
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
pixels,
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}
bool _hasBlackBackdrop(Uint8List pixels, int width, int height) {
if (width < 2 || height < 2) return false;
final samples = <(int, int)>[
(0, 0),
(width - 1, 0),
(0, height - 1),
(width - 1, height - 1),
(width ~/ 2, 0),
(width ~/ 2, height - 1),
(0, height ~/ 2),
(width - 1, height ~/ 2),
];
var blackSamples = 0;
for (final (x, y) in samples) {
final i = (y * width + x) * 4;
final r = pixels[i];
final g = pixels[i + 1];
final b = pixels[i + 2];
final a = pixels[i + 3];
// Opaque near-black corner/edge => baked-in backdrop.
if (a > 200 && r <= 28 && g <= 28 && b <= 28) {
blackSamples++;
}
}
return blackSamples >= 5;
}
@override
Widget build(BuildContext context) {
if (_failed || _processed == null) {
return widget.fallback;
}
return SizedBox(
width: widget.width,
height: widget.height,
child: FittedBox(
fit: widget.fit,
child: SizedBox(
width: _processed!.width.toDouble(),
height: _processed!.height.toDouble(),
child: RawImage(
image: _processed,
fit: BoxFit.fill,
),
),
),
);
}
}
/// Resolves logo URL from company profile, branding, or bundled default.