UI changes review
This commit is contained in:
parent
5a28cfc0da
commit
3914e78465
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 244 KiB |
38
lib/core/utils/active_option.dart
Normal file
38
lib/core/utils/active_option.dart
Normal 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);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(', ');
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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() ?? '',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
67
lib/shared/widgets/app_form_toggle_field.dart
Normal file
67
lib/shared/widgets/app_form_toggle_field.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
227
lib/shared/widgets/app_responsive_filter_bar.dart
Normal file
227
lib/shared/widgets/app_responsive_filter_bar.dart
Normal 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 2–3 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user