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,
|
ApiEndpoints.itemCategories,
|
||||||
queryParameters: const {'limit': 100, 'is_active': true},
|
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 {
|
Future<AssetDropdownOptionsModel> getAssetOptions() async {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
import '../../../../shared/models/grn_model.dart';
|
import '../../../../shared/models/grn_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
@ -116,22 +117,41 @@ Future<List<FilterOptionModel>> _safeOptions(
|
|||||||
|
|
||||||
Future<List<FilterOptionModel>> _safeVendorOptions(Ref ref) async {
|
Future<List<FilterOptionModel>> _safeVendorOptions(Ref ref) async {
|
||||||
try {
|
try {
|
||||||
final result = await ref.read(vendorRepositoryProvider).getVendors(
|
final vendors = <FilterOptionModel>[];
|
||||||
const VendorListQuery(
|
var page = 1;
|
||||||
page: 1,
|
var totalPages = 1;
|
||||||
limit: AppConstants.maxPageSize,
|
|
||||||
status: 'active',
|
while (page <= totalPages) {
|
||||||
),
|
final result = await ref.read(vendorRepositoryProvider).getVendors(
|
||||||
);
|
VendorListQuery(
|
||||||
if (result.failure != null || result.data == null) return const [];
|
page: page,
|
||||||
return result.data!.items
|
limit: AppConstants.maxPageSize,
|
||||||
.map(
|
isActive: true,
|
||||||
(vendor) => FilterOptionModel(
|
),
|
||||||
id: vendor.id,
|
);
|
||||||
name: vendor.vendorName,
|
if (result.failure != null || result.data == null) return vendors;
|
||||||
),
|
|
||||||
)
|
final data = result.data!;
|
||||||
.toList();
|
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 (_) {
|
} catch (_) {
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
@ -149,10 +169,12 @@ Future<List<FilterOptionModel>> _safeUserOptions(Ref ref) async {
|
|||||||
);
|
);
|
||||||
if (result.failure != null || result.data == null) return const [];
|
if (result.failure != null || result.data == null) return const [];
|
||||||
return result.data!.items
|
return result.data!.items
|
||||||
.where((user) {
|
.where(
|
||||||
final status = user.status.trim().toLowerCase();
|
(user) => isActiveUserOption(
|
||||||
return status == 'active' && user.isActive;
|
status: user.status,
|
||||||
})
|
isActive: user.isActive,
|
||||||
|
),
|
||||||
|
)
|
||||||
.map(
|
.map(
|
||||||
(user) => FilterOptionModel(
|
(user) => FilterOptionModel(
|
||||||
id: user.id,
|
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_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
@ -135,21 +136,15 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: LayoutBuilder(
|
child: _AssetsFilterBar(
|
||||||
builder: (context, constraints) {
|
query: state.query,
|
||||||
return _AssetsFilterBar(
|
categories: allCategories,
|
||||||
wrapped: constraints.maxWidth < 1000,
|
plants: allPlants,
|
||||||
query: state.query,
|
statuses: lookups?.statuses ?? const [],
|
||||||
categories: allCategories,
|
onSearch: notifier.setSearch,
|
||||||
plants: allPlants,
|
onCategoryChanged: notifier.setCategoryFilter,
|
||||||
statuses: lookups?.statuses ?? const [],
|
onPlantChanged: notifier.setPlantFilter,
|
||||||
onSearch: notifier.setSearch,
|
onStatusChanged: notifier.setStatusFilter,
|
||||||
onCategoryChanged:
|
|
||||||
notifier.setCategoryFilter,
|
|
||||||
onPlantChanged: notifier.setPlantFilter,
|
|
||||||
onStatusChanged: notifier.setStatusFilter,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
@ -275,7 +270,6 @@ class _AssetListScreenState extends ConsumerState<AssetListScreen> {
|
|||||||
|
|
||||||
class _AssetsFilterBar extends StatelessWidget {
|
class _AssetsFilterBar extends StatelessWidget {
|
||||||
const _AssetsFilterBar({
|
const _AssetsFilterBar({
|
||||||
required this.wrapped,
|
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.categories,
|
required this.categories,
|
||||||
required this.plants,
|
required this.plants,
|
||||||
@ -286,7 +280,6 @@ class _AssetsFilterBar extends StatelessWidget {
|
|||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool wrapped;
|
|
||||||
final AssetListQuery query;
|
final AssetListQuery query;
|
||||||
final List<AssetCategoryModel> categories;
|
final List<AssetCategoryModel> categories;
|
||||||
final List<FilterOptionModel> plants;
|
final List<FilterOptionModel> plants;
|
||||||
@ -315,7 +308,7 @@ class _AssetsFilterBar extends StatelessWidget {
|
|||||||
final categoryOptions = <AppDropdownOption<int?>>[
|
final categoryOptions = <AppDropdownOption<int?>>[
|
||||||
const AppDropdownOption(value: null, label: 'All Categories'),
|
const AppDropdownOption(value: null, label: 'All Categories'),
|
||||||
for (final category in categories)
|
for (final category in categories)
|
||||||
if (int.tryParse(category.id) != null)
|
if (category.isActive && int.tryParse(category.id) != null)
|
||||||
AppDropdownOption(
|
AppDropdownOption(
|
||||||
value: int.parse(category.id),
|
value: int.parse(category.id),
|
||||||
label: category.name,
|
label: category.name,
|
||||||
@ -371,29 +364,9 @@ class _AssetsFilterBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (wrapped) {
|
return AppResponsiveFilterBar(
|
||||||
return Column(
|
search: searchField,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
filters: filters,
|
||||||
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]),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../../../../shared/models/user_management_models.dart' show FilterOption
|
|||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_date_popup.dart';
|
import '../../../../shared/widgets/app_date_popup.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
@ -880,10 +881,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
subtitle: 'Inactive assets are hidden from active lists',
|
||||||
subtitle: const Text('Inactive assets are hidden from active lists'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (value) => setState(() => _isActive = value),
|
onChanged: (value) => setState(() => _isActive = value),
|
||||||
),
|
),
|
||||||
@ -1079,7 +1079,9 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _categoryDropdown(List<AssetCategoryModel> categories) {
|
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))
|
.map((c) => int.tryParse(c.id))
|
||||||
.whereType<int>()
|
.whereType<int>()
|
||||||
.toList();
|
.toList();
|
||||||
@ -1089,7 +1091,7 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
value: _dropdownValue(_categoryId, categoryIds),
|
value: _dropdownValue(_categoryId, categoryIds),
|
||||||
searchHint: 'Search category...',
|
searchHint: 'Search category...',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
options: categories
|
options: activeCategories
|
||||||
.map(
|
.map(
|
||||||
(c) => AppDropdownOption(
|
(c) => AppDropdownOption(
|
||||||
value: int.tryParse(c.id) ?? 0,
|
value: int.tryParse(c.id) ?? 0,
|
||||||
@ -1105,7 +1107,8 @@ class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
|
|||||||
onChanged: (v) => setState(() {
|
onChanged: (v) => setState(() {
|
||||||
_categoryId = v;
|
_categoryId = v;
|
||||||
_subcategoryId = null;
|
_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 != null) {
|
||||||
if (selectedCategory.defaultDepreciationMethod != null &&
|
if (selectedCategory.defaultDepreciationMethod != null &&
|
||||||
selectedCategory.defaultDepreciationMethod!.trim().isNotEmpty) {
|
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_date_popup.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.dart';
|
import '../../../../shared/widgets/app_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_empty_state.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
@ -360,9 +361,8 @@ class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
|
|||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (value) => setState(() => _isActive = value),
|
onChanged: (value) => setState(() => _isActive = value),
|
||||||
),
|
),
|
||||||
@ -804,9 +804,8 @@ class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
|
|||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Under AMC',
|
||||||
title: const Text('Under AMC'),
|
|
||||||
value: _isUnderAmc,
|
value: _isUnderAmc,
|
||||||
onChanged: (value) => setState(() => _isUnderAmc = value),
|
onChanged: (value) => setState(() => _isUnderAmc = value),
|
||||||
),
|
),
|
||||||
@ -1151,21 +1150,18 @@ class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Auto Renewal',
|
||||||
title: const Text('Auto Renewal'),
|
|
||||||
value: _isAutoRenewal,
|
value: _isAutoRenewal,
|
||||||
onChanged: (value) => setState(() => _isAutoRenewal = value),
|
onChanged: (value) => setState(() => _isAutoRenewal = value),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Premium Paid',
|
||||||
title: const Text('Premium Paid'),
|
|
||||||
value: _premiumPaid,
|
value: _premiumPaid,
|
||||||
onChanged: (value) => setState(() => _premiumPaid = value),
|
onChanged: (value) => setState(() => _premiumPaid = value),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (value) => setState(() => _isActive = value),
|
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_filter_date_field.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
@ -151,24 +153,19 @@ class _AuditLogsScreenState extends ConsumerState<AuditLogsScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableShell(
|
child: AppTableShell(
|
||||||
toolbar: LayoutBuilder(
|
toolbar: _FiltersBar(
|
||||||
builder: (context, constraints) {
|
searchController: _searchController,
|
||||||
return _FiltersBar(
|
filters: state.filters,
|
||||||
searchController: _searchController,
|
query: state.query,
|
||||||
filters: state.filters,
|
onSearch: notifier.setSearch,
|
||||||
query: state.query,
|
onTableChanged: notifier.setTableName,
|
||||||
wrapped: constraints.maxWidth < 1100,
|
onActionChanged: notifier.setAction,
|
||||||
onSearch: notifier.setSearch,
|
onPerformerChanged: notifier.setPerformedBy,
|
||||||
onTableChanged: notifier.setTableName,
|
onPickDateRange: () => _pickDateRange(state.query),
|
||||||
onActionChanged: notifier.setAction,
|
onClearDateRange: () => notifier.setDateRange(null, null),
|
||||||
onPerformerChanged: notifier.setPerformedBy,
|
onReset: () {
|
||||||
onPickDateRange: () => _pickDateRange(state.query),
|
_searchController.clear();
|
||||||
onClearDateRange: () => notifier.setDateRange(null, null),
|
notifier.resetFilters();
|
||||||
onReset: () {
|
|
||||||
_searchController.clear();
|
|
||||||
notifier.resetFilters();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
footer: AppPagination(
|
footer: AppPagination(
|
||||||
@ -237,7 +234,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.filters,
|
required this.filters,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.wrapped,
|
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onTableChanged,
|
required this.onTableChanged,
|
||||||
required this.onActionChanged,
|
required this.onActionChanged,
|
||||||
@ -250,7 +246,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final AuditLogFilterOptions filters;
|
final AuditLogFilterOptions filters;
|
||||||
final AuditLogListQuery query;
|
final AuditLogListQuery query;
|
||||||
final bool wrapped;
|
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onTableChanged;
|
final ValueChanged<String?> onTableChanged;
|
||||||
final ValueChanged<String?> onActionChanged;
|
final ValueChanged<String?> onActionChanged;
|
||||||
@ -270,24 +265,11 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final searchField = TextField(
|
final searchField = AppSearchField(
|
||||||
controller: searchController,
|
controller: searchController,
|
||||||
|
hint: 'Search table, action, request ID...',
|
||||||
onChanged: onSearch,
|
onChanged: onSearch,
|
||||||
decoration: InputDecoration(
|
onClear: () => onSearch(''),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final tableDropdown = AppSearchableDropdown<String?>(
|
final tableDropdown = AppSearchableDropdown<String?>(
|
||||||
@ -350,51 +332,17 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
child: const Text('Reset'),
|
child: const Text('Reset'),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (wrapped) {
|
return AppResponsiveFilterGrid(
|
||||||
return Column(
|
fields: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
searchField,
|
||||||
children: [
|
tableDropdown,
|
||||||
searchField,
|
actionDropdown,
|
||||||
const SizedBox(height: 12),
|
performerDropdown,
|
||||||
tableDropdown,
|
dateField,
|
||||||
const SizedBox(height: 12),
|
Align(
|
||||||
actionDropdown,
|
alignment: Alignment.centerRight,
|
||||||
const SizedBox(height: 12),
|
child: resetButton,
|
||||||
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),
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,9 @@ class _DetailBody extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final hasOld = detail.oldValue != null || detail.hasOldValue;
|
||||||
|
final hasNew = detail.newValue != null || detail.hasNewValue;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -69,20 +72,7 @@ class _DetailBody extends StatelessWidget {
|
|||||||
_DetailRow(label: 'Request ID', value: detail.requestId ?? '—'),
|
_DetailRow(label: 'Request ID', value: detail.requestId ?? '—'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (detail.oldValue != null || detail.hasOldValue)
|
if (hasOld && hasNew)
|
||||||
_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))
|
|
||||||
_ChangedFieldsSection(
|
_ChangedFieldsSection(
|
||||||
oldValue: detail.oldValue,
|
oldValue: detail.oldValue,
|
||||||
newValue: detail.newValue,
|
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 {
|
class _ChangedFieldsSection extends StatelessWidget {
|
||||||
const _ChangedFieldsSection({
|
const _ChangedFieldsSection({
|
||||||
required this.oldValue,
|
required this.oldValue,
|
||||||
@ -238,7 +178,10 @@ class _ChangedFieldsSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 18,
|
||||||
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.arrow_forward,
|
Icons.arrow_forward,
|
||||||
size: 16,
|
size: 16,
|
||||||
@ -385,7 +328,10 @@ String? _nestedSummary(Map<String, dynamic> nested) {
|
|||||||
|
|
||||||
if (nested.length <= 2) {
|
if (nested.length <= 2) {
|
||||||
return nested.entries
|
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(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../masters/data/datasources/master_remote_data_source.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 [];
|
if (result.failure != null || result.data == null) return const [];
|
||||||
return result.data!.items
|
return result.data!.items
|
||||||
.where((user) {
|
.where(
|
||||||
final status = user.status.trim().toLowerCase();
|
(user) => isActiveUserOption(
|
||||||
return status == 'active' && user.isActive;
|
status: user.status,
|
||||||
})
|
isActive: user.isActive,
|
||||||
|
),
|
||||||
|
)
|
||||||
.map(
|
.map(
|
||||||
(user) => FilterOptionModel(
|
(user) => FilterOptionModel(
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@ -318,7 +318,7 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
: const AsyncData<GrnModel?>(null);
|
: const AsyncData<GrnModel?>(null);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: lookupsAsync.when(
|
body: lookupsAsync.when(
|
||||||
skipLoadingOnReload: true,
|
skipLoadingOnReload: true,
|
||||||
loading: () => lookupsAsync.hasValue
|
loading: () => lookupsAsync.hasValue
|
||||||
@ -388,236 +388,230 @@ class _GrnFormScreenState extends ConsumerState<GrnFormScreen> {
|
|||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||||
child: Align(
|
child: Form(
|
||||||
alignment: Alignment.topCenter,
|
key: _formKey,
|
||||||
child: ConstrainedBox(
|
child: Column(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Form(
|
children: [
|
||||||
key: _formKey,
|
_buildHeader(existing),
|
||||||
child: Column(
|
const SizedBox(height: 16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
_SectionCard(
|
||||||
children: [
|
title: 'RECEIPT DETAILS',
|
||||||
_buildHeader(existing),
|
child: Column(
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionCard(
|
|
||||||
title: 'RECEIPT DETAILS',
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
FormRowFour(
|
|
||||||
children: [
|
children: [
|
||||||
_DateField(
|
FormRowFour(
|
||||||
label: 'GRN Date *',
|
children: [
|
||||||
value: _grnDate,
|
_DateField(
|
||||||
enabled: !widget.isEditing,
|
label: 'GRN Date *',
|
||||||
onTap: widget.isEditing
|
value: _grnDate,
|
||||||
? null
|
enabled: !widget.isEditing,
|
||||||
: () => _pickDate(
|
onTap: widget.isEditing
|
||||||
current: _grnDate,
|
? null
|
||||||
onPicked: (d) =>
|
: () => _pickDate(
|
||||||
setState(() => _grnDate = d),
|
current: _grnDate,
|
||||||
),
|
onPicked: (d) =>
|
||||||
),
|
setState(() => _grnDate = d),
|
||||||
if (!widget.isEditing)
|
),
|
||||||
AppSearchableDropdown<String>(
|
),
|
||||||
label: 'Purchase Order *',
|
if (!widget.isEditing)
|
||||||
value: _selectedPoId,
|
AppSearchableDropdown<String>(
|
||||||
hint: 'Select PO',
|
label: 'Purchase Order *',
|
||||||
searchHint: 'Search PO...',
|
value: _selectedPoId,
|
||||||
options: poOptions,
|
hint: 'Select PO',
|
||||||
onChanged: (v) async {
|
searchHint: 'Search PO...',
|
||||||
setState(() => _selectedPoId = v);
|
options: poOptions,
|
||||||
if (v == null) {
|
onChanged: (v) async {
|
||||||
for (final line in _lines) {
|
setState(() => _selectedPoId = v);
|
||||||
line.dispose();
|
if (v == null) {
|
||||||
}
|
for (final line in _lines) {
|
||||||
setState(() => _lines.clear());
|
line.dispose();
|
||||||
return;
|
}
|
||||||
}
|
setState(() => _lines.clear());
|
||||||
try {
|
return;
|
||||||
final po = await ref.read(
|
}
|
||||||
grnPurchaseOrderProvider(v).future,
|
try {
|
||||||
);
|
final po = await ref.read(
|
||||||
if (mounted && po != null) {
|
grnPurchaseOrderProvider(v).future,
|
||||||
_loadLinesFromPo(po);
|
);
|
||||||
}
|
if (mounted && po != null) {
|
||||||
} catch (e) {
|
_loadLinesFromPo(po);
|
||||||
if (!mounted) return;
|
}
|
||||||
showAppToastFromSnackBar(context,
|
} catch (e) {
|
||||||
SnackBar(content: Text(e.toString())),
|
if (!mounted) return;
|
||||||
);
|
showAppToastFromSnackBar(context,
|
||||||
}
|
SnackBar(content: Text(e.toString())),
|
||||||
},
|
);
|
||||||
validator: (v) => v == null
|
}
|
||||||
? 'Purchase Order Is Required'
|
},
|
||||||
: null,
|
validator: (v) => v == null
|
||||||
)
|
? 'Purchase Order Is Required'
|
||||||
else
|
: null,
|
||||||
_ReadOnlyField(
|
)
|
||||||
label: 'Purchase Order',
|
else
|
||||||
value: existing?.poNumber ?? '—',
|
_ReadOnlyField(
|
||||||
),
|
label: 'Purchase Order',
|
||||||
MasterQuickAddDropdown<int>(
|
value: existing?.poNumber ?? '—',
|
||||||
masterId: 'warehouses',
|
),
|
||||||
label: 'Warehouse *',
|
MasterQuickAddDropdown<int>(
|
||||||
value: _dropdownValue(_warehouseId, warehouseIds),
|
masterId: 'warehouses',
|
||||||
hint: 'Select warehouse',
|
label: 'Warehouse *',
|
||||||
searchHint: 'Search warehouse...',
|
value: _dropdownValue(_warehouseId, warehouseIds),
|
||||||
options: _intOptions(lookups.warehouses),
|
hint: 'Select warehouse',
|
||||||
refreshLookups: () =>
|
searchHint: 'Search warehouse...',
|
||||||
ref.invalidate(grnLookupsProvider),
|
options: _intOptions(lookups.warehouses),
|
||||||
parseCreatedId: int.tryParse,
|
refreshLookups: () =>
|
||||||
onChanged: widget.isEditing
|
ref.invalidate(grnLookupsProvider),
|
||||||
? (_) {}
|
parseCreatedId: int.tryParse,
|
||||||
: (v) => setState(() => _warehouseId = v),
|
onChanged: widget.isEditing
|
||||||
validator: widget.isEditing
|
? (_) {}
|
||||||
? null
|
: (v) => setState(() => _warehouseId = v),
|
||||||
: (v) =>
|
validator: widget.isEditing
|
||||||
v == null ? 'Warehouse is required' : null,
|
? null
|
||||||
enabled: !widget.isEditing,
|
: (v) =>
|
||||||
),
|
v == null ? 'Warehouse is required' : null,
|
||||||
AppTextField(
|
enabled: !widget.isEditing,
|
||||||
label: 'Vendor Invoice No',
|
),
|
||||||
controller: _vendorInvoiceNoController,
|
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}'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
validator: (v) => Validators.optionalNonNegativeDouble(
|
|
||||||
v,
|
|
||||||
fieldName: 'Vendor Invoice Amount',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
AppTextField(
|
FormRowFour(
|
||||||
label: 'Vehicle No',
|
children: [
|
||||||
controller: _vehicleNoController,
|
_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(
|
FormRowFour(
|
||||||
label: 'LR No',
|
children: [
|
||||||
controller: _lrNoController,
|
_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: [
|
const SizedBox(height: 16),
|
||||||
_DateField(
|
if (!widget.isEditing)
|
||||||
label: 'LR Date',
|
GrnLineItemsEditor(
|
||||||
value: _lrDate,
|
items: _lines,
|
||||||
onTap: () => _pickDate(
|
onChanged: () => setState(() {}),
|
||||||
current: _lrDate,
|
)
|
||||||
onPicked: (d) => setState(() => _lrDate = d),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (existing?.items.isNotEmpty == true) ...[
|
||||||
AppSearchableDropdown<int>(
|
const SizedBox(height: 12),
|
||||||
label: 'Received By',
|
GrnItemsTable(items: existing!.items),
|
||||||
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(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
_SectionCard(
|
||||||
const SizedBox(height: 16),
|
title: 'ADDITIONAL DETAILS',
|
||||||
if (!widget.isEditing)
|
child: AppTextField(
|
||||||
GrnLineItemsEditor(
|
controller: _remarksController,
|
||||||
items: _lines,
|
label: 'Remarks',
|
||||||
onChanged: () => setState(() {}),
|
hint: 'Any additional notes for this receipt.',
|
||||||
)
|
maxLines: 4,
|
||||||
else ...[
|
),
|
||||||
_SectionCard(
|
),
|
||||||
title: 'LINE ITEMS · ${existing?.items.length ?? 0}',
|
const SizedBox(height: 20),
|
||||||
child: Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Line items cannot be changed after posting.',
|
'Fields marked * are required',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
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,
|
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_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||||
@ -83,20 +84,15 @@ class _GrnListScreenState extends ConsumerState<GrnListScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableShell(
|
child: AppTableShell(
|
||||||
toolbar: LayoutBuilder(
|
toolbar: _FiltersBar(
|
||||||
builder: (context, constraints) {
|
searchController: _searchController,
|
||||||
return _FiltersBar(
|
query: state.query,
|
||||||
searchController: _searchController,
|
showExport: canExport,
|
||||||
query: state.query,
|
isExporting: state.isExporting,
|
||||||
wrapped: constraints.maxWidth < 900,
|
onExport: _exportGrns,
|
||||||
showExport: canExport,
|
onSearch: ref.read(grnListProvider.notifier).setSearch,
|
||||||
isExporting: state.isExporting,
|
onStatusChanged:
|
||||||
onExport: _exportGrns,
|
ref.read(grnListProvider.notifier).setStatusFilter,
|
||||||
onSearch: ref.read(grnListProvider.notifier).setSearch,
|
|
||||||
onStatusChanged:
|
|
||||||
ref.read(grnListProvider.notifier).setStatusFilter,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
footer: AppPagination(
|
footer: AppPagination(
|
||||||
currentPage: state.query.page,
|
currentPage: state.query.page,
|
||||||
@ -191,7 +187,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
const _FiltersBar({
|
const _FiltersBar({
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.wrapped,
|
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
this.showExport = false,
|
this.showExport = false,
|
||||||
@ -201,7 +196,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
|
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final GrnListQuery query;
|
final GrnListQuery query;
|
||||||
final bool wrapped;
|
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
final bool showExport;
|
final bool showExport;
|
||||||
@ -212,25 +206,21 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final searchField = Padding(
|
final searchField = Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: SizedBox(
|
child: TextField(
|
||||||
width: wrapped ? double.infinity : null,
|
controller: searchController,
|
||||||
child: TextField(
|
onChanged: onSearch,
|
||||||
controller: searchController,
|
decoration: const InputDecoration(
|
||||||
onChanged: onSearch,
|
labelText: 'Search',
|
||||||
decoration: const InputDecoration(
|
hintText: 'Search GRN number, PO, vendor...',
|
||||||
labelText: 'Search',
|
prefixIcon: Icon(Icons.search, size: 20),
|
||||||
hintText: 'Search GRN number, PO, vendor...',
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
prefixIcon: Icon(Icons.search, size: 20),
|
isDense: true,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final statusFilter = SizedBox(
|
final filters = [
|
||||||
width: wrapped ? double.infinity : 180,
|
AppSearchableDropdown<String?>(
|
||||||
child: AppSearchableDropdown<String?>(
|
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: query.status,
|
value: query.status,
|
||||||
searchHint: 'Search status...',
|
searchHint: 'Search status...',
|
||||||
@ -243,46 +233,24 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onChanged: onStatusChanged,
|
onChanged: onStatusChanged,
|
||||||
),
|
),
|
||||||
);
|
];
|
||||||
|
|
||||||
final exportButton = OutlinedButton.icon(
|
return AppResponsiveFilterBar(
|
||||||
onPressed: isExporting ? null : onExport,
|
search: searchField,
|
||||||
icon: isExporting
|
filters: filters,
|
||||||
? const SizedBox(
|
trailing: showExport
|
||||||
width: 18,
|
? OutlinedButton.icon(
|
||||||
height: 18,
|
onPressed: isExporting ? null : onExport,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
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),
|
: null,
|
||||||
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,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -664,11 +664,6 @@ class _GrnItemsHeader extends StatelessWidget {
|
|||||||
child: Text('CURRENT', style: style, textAlign: TextAlign.right),
|
child: Text('CURRENT', style: style, textAlign: TextAlign.right),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
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)),
|
SizedBox(width: 90, child: Text('BATCH', style: style)),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
SizedBox(width: 90, child: Text('MFG', style: style)),
|
SizedBox(width: 90, child: Text('MFG', style: style)),
|
||||||
@ -714,11 +709,38 @@ class _GrnItemRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
child: Text(
|
child: Column(
|
||||||
item.itemName ?? item.itemCode ?? '—',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: strong,
|
children: [
|
||||||
maxLines: 2,
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
const SizedBox(width: 12),
|
||||||
@ -749,15 +771,6 @@ class _GrnItemRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
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(
|
SizedBox(
|
||||||
width: 90,
|
width: 90,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/export_file_result.dart';
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../domain/entities/master_definition.dart';
|
import '../../domain/entities/master_definition.dart';
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ class MasterCrudRemoteDataSource {
|
|||||||
int page = 1,
|
int page = 1,
|
||||||
int limit = 20,
|
int limit = 20,
|
||||||
String? search,
|
String? search,
|
||||||
|
bool? isActive,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await dio.get(
|
final response = await dio.get(
|
||||||
definition.apiPath,
|
definition.apiPath,
|
||||||
@ -48,6 +50,7 @@ class MasterCrudRemoteDataSource {
|
|||||||
'page': page,
|
'page': page,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
if (search != null && search.isNotEmpty) 'search': search,
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
|
if (isActive != null) 'is_active': isActive,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -85,10 +88,9 @@ class MasterCrudRemoteDataSource {
|
|||||||
definition,
|
definition,
|
||||||
page: page,
|
page: page,
|
||||||
limit: AppConstants.defaultPageSize,
|
limit: AppConstants.defaultPageSize,
|
||||||
|
isActive: true,
|
||||||
);
|
);
|
||||||
allItems.addAll(
|
allItems.addAll(result.items.where(isActiveOptionRow));
|
||||||
result.items.where((item) => item['is_active'] != false),
|
|
||||||
);
|
|
||||||
if (page >= result.totalPages) break;
|
if (page >= result.totalPages) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -189,18 +189,13 @@ class _MasterListScreenState extends ConsumerState<MasterListScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableShell(
|
child: AppTableShell(
|
||||||
toolbar: LayoutBuilder(
|
toolbar: AppSearchExportBar(
|
||||||
builder: (context, constraints) {
|
searchController: _searchController,
|
||||||
return AppSearchExportBar(
|
searchHint: _searchHint(def),
|
||||||
wrapped: constraints.maxWidth < 640,
|
isExporting: state.isExporting,
|
||||||
searchController: _searchController,
|
showExport: canExport,
|
||||||
searchHint: _searchHint(def),
|
onSearch: notifier.setSearch,
|
||||||
isExporting: state.isExporting,
|
onExport: _exportRecords,
|
||||||
showExport: canExport,
|
|
||||||
onSearch: notifier.setSearch,
|
|
||||||
onExport: _exportRecords,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
footer: AppPagination(
|
footer: AppPagination(
|
||||||
currentPage: state.page,
|
currentPage: state.page,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart';
|
|||||||
import '../../../../core/utils/validators.dart';
|
import '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
@ -112,9 +113,8 @@ class _MasterFormPanelState extends ConsumerState<MasterFormPanel> {
|
|||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case MasterFieldType.boolean:
|
case MasterFieldType.boolean:
|
||||||
if (field.key == 'is_active') {
|
if (field.key == 'is_active') {
|
||||||
return SwitchListTile(
|
return AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: field.label,
|
||||||
title: Text(field.label),
|
|
||||||
value: value == true,
|
value: value == true,
|
||||||
onChanged: (checked) => notifier.updateValue(field.key, checked),
|
onChanged: (checked) => notifier.updateValue(field.key, checked),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import '../../../../core/errors/failure.dart';
|
|||||||
import '../../../../core/utils/validators.dart';
|
import '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
@ -103,10 +104,8 @@ class _MasterInlineCreateFormState
|
|||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case MasterFieldType.boolean:
|
case MasterFieldType.boolean:
|
||||||
if (field.key == 'is_active') {
|
if (field.key == 'is_active') {
|
||||||
return SwitchListTile(
|
return AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: field.label,
|
||||||
dense: true,
|
|
||||||
title: Text(field.label),
|
|
||||||
value: value == true,
|
value: value == true,
|
||||||
onChanged: (checked) => notifier.updateValue(field.key, checked),
|
onChanged: (checked) => notifier.updateValue(field.key, checked),
|
||||||
);
|
);
|
||||||
@ -363,20 +362,38 @@ class _MasterInlineCreateFormState
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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 formAsync = ref.watch(masterFormProvider(_args));
|
||||||
final def = _definition;
|
final def = _definition;
|
||||||
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
final isSubmitting = formAsync.valueOrNull?.isSubmitting ?? false;
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
color: quickAddBackground,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||||
@ -447,29 +464,40 @@ class _MasterInlineCreateFormState
|
|||||||
formState: formState,
|
formState: formState,
|
||||||
twoColumns: useTwoColumns,
|
twoColumns: useTwoColumns,
|
||||||
),
|
),
|
||||||
if (active != null) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_buildField(
|
|
||||||
field: active,
|
|
||||||
formState: formState,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
if (active != null)
|
||||||
onPressed:
|
Expanded(
|
||||||
isSubmitting ? null : widget.onCancel,
|
child: Align(
|
||||||
child: const Text('Cancel'),
|
alignment: Alignment.centerLeft,
|
||||||
),
|
child: _buildField(
|
||||||
const SizedBox(width: 8),
|
field: active,
|
||||||
AppButton(
|
formState: formState,
|
||||||
label: 'Add ${def.title}',
|
),
|
||||||
expand: false,
|
),
|
||||||
icon: Icons.check,
|
)
|
||||||
isLoading: isSubmitting,
|
else
|
||||||
onPressed: isSubmitting ? null : _submit,
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@ -47,7 +49,9 @@ class MasterQuickAddDropdown<T> extends ConsumerStatefulWidget {
|
|||||||
final bool enabled;
|
final bool enabled;
|
||||||
final bool isDense;
|
final bool isDense;
|
||||||
final Map<String, dynamic>? initialValues;
|
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;
|
final String? addNewLabel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -114,8 +118,14 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSaved(String createdId) {
|
Future<void> _onSaved(String createdId) async {
|
||||||
widget.refreshLookups?.call();
|
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') {
|
if (createdId != 'created') {
|
||||||
final parsed = widget.parseCreatedId(createdId);
|
final parsed = widget.parseCreatedId(createdId);
|
||||||
@ -127,7 +137,8 @@ class _MasterQuickAddDropdownState<T>
|
|||||||
_collapse();
|
_collapse();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content:
|
content:
|
||||||
Text('${_titleCase(masterQuickAddNoun(widget.masterId))} added'),
|
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/api_endpoints.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
|
|
||||||
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
|
final masterRemoteDataSourceProvider = Provider<MasterRemoteDataSource>((ref) {
|
||||||
@ -65,7 +66,7 @@ class MasterRemoteDataSource {
|
|||||||
final options = <FilterOptionModel>[];
|
final options = <FilterOptionModel>[];
|
||||||
|
|
||||||
for (final item in rows) {
|
for (final item in rows) {
|
||||||
if (item['is_active'] == false) continue;
|
if (!isActiveOptionRow(item)) continue;
|
||||||
final id = item['id']?.toString() ?? '';
|
final id = item['id']?.toString() ?? '';
|
||||||
if (id.isEmpty) continue;
|
if (id.isEmpty) continue;
|
||||||
|
|
||||||
@ -104,7 +105,7 @@ class MasterRemoteDataSource {
|
|||||||
final pctById = <String, double>{};
|
final pctById = <String, double>{};
|
||||||
|
|
||||||
for (final item in rows) {
|
for (final item in rows) {
|
||||||
if (item['is_active'] == false) continue;
|
if (!isActiveOptionRow(item)) continue;
|
||||||
final id = item['id']?.toString() ?? '';
|
final id = item['id']?.toString() ?? '';
|
||||||
if (id.isEmpty) continue;
|
if (id.isEmpty) continue;
|
||||||
|
|
||||||
@ -135,7 +136,7 @@ class MasterRemoteDataSource {
|
|||||||
);
|
);
|
||||||
final categoryFilter = itemCategoryId?.toString();
|
final categoryFilter = itemCategoryId?.toString();
|
||||||
return rows
|
return rows
|
||||||
.where((item) => item['is_active'] != false)
|
.where(isActiveOptionRow)
|
||||||
.where(
|
.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
categoryFilter == null ||
|
categoryFilter == null ||
|
||||||
@ -157,7 +158,7 @@ class MasterRemoteDataSource {
|
|||||||
queryParameters: {'is_active': true},
|
queryParameters: {'is_active': true},
|
||||||
);
|
);
|
||||||
return rows
|
return rows
|
||||||
.where((item) => item['is_active'] != false)
|
.where(isActiveOptionRow)
|
||||||
.map(
|
.map(
|
||||||
(item) => FilterOptionModel(
|
(item) => FilterOptionModel(
|
||||||
id: item['id']?.toString() ?? '',
|
id: item['id']?.toString() ?? '',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
||||||
@ -157,11 +158,12 @@ Future<List<FilterOptionModel>> _fetchActiveVendors(
|
|||||||
final data = result.data!;
|
final data = result.data!;
|
||||||
vendors.addAll(
|
vendors.addAll(
|
||||||
data.items
|
data.items
|
||||||
.where((vendor) {
|
.where(
|
||||||
if (!vendor.isActive) return false;
|
(vendor) => isActiveVendorOption(
|
||||||
final status = (vendor.status ?? '').trim().toLowerCase();
|
isActive: vendor.isActive,
|
||||||
return status != 'blacklisted';
|
status: vendor.status,
|
||||||
})
|
),
|
||||||
|
)
|
||||||
.map(
|
.map(
|
||||||
(vendor) =>
|
(vendor) =>
|
||||||
FilterOptionModel(id: vendor.id, name: vendor.vendorName),
|
FilterOptionModel(id: vendor.id, name: vendor.vendorName),
|
||||||
|
|||||||
@ -366,243 +366,238 @@ class _PurchaseOrderFormScreenState extends ConsumerState<PurchaseOrderFormScree
|
|||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
|
||||||
child: Align(
|
child: Form(
|
||||||
alignment: Alignment.topCenter,
|
key: _formKey,
|
||||||
child: ConstrainedBox(
|
child: Column(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Form(
|
children: [
|
||||||
key: _formKey,
|
_buildHeader(existing),
|
||||||
child: Column(
|
if (_showReapprovalWarning(existing)) ...[
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
const SizedBox(height: 8),
|
||||||
children: [
|
_ReapprovalBanner(),
|
||||||
_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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
_SectionCard(
|
||||||
const SizedBox(height: 16),
|
title: 'ORDER DETAILS',
|
||||||
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AppTextField(
|
FormRowFour(
|
||||||
controller: _termsController,
|
children: [
|
||||||
label: 'Terms & Conditions',
|
_DateField(
|
||||||
hint: 'Payment terms, inspection conditions, etc.',
|
label: 'PO Date *',
|
||||||
maxLines: 5,
|
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(
|
FormRowFour(
|
||||||
controller: _remarksController,
|
children: [
|
||||||
label: 'Remarks',
|
MasterQuickAddDropdown<int?>(
|
||||||
hint: 'Any additional notes for this order.',
|
masterId: 'warehouses',
|
||||||
maxLines: 4,
|
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(),
|
const SizedBox(height: 16),
|
||||||
Text(
|
PurchaseOrderLineItemsEditor(
|
||||||
widget.isEditing
|
lines: _lines,
|
||||||
? '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · editing existing draft'
|
items: lookups.items,
|
||||||
: '${_lines.length} line item${_lines.length == 1 ? '' : 's'} · draft not yet saved',
|
itemHsnById: lookups.itemHsnById,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
itemUomById: lookups.itemUomById,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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);
|
final lookupsAsync = ref.watch(purchaseOrderLookupsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: lookupsAsync.when(
|
body: lookupsAsync.when(
|
||||||
loading: () => const AppLoadingView(message: 'Loading form options...'),
|
loading: () => const AppLoadingView(message: 'Loading form options...'),
|
||||||
error: (e, _) => ErrorView.fromFailure(
|
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_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_search_field.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
@ -103,23 +104,18 @@ class _PurchaseOrderListScreenState extends ConsumerState<PurchaseOrderListScree
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableShell(
|
child: AppTableShell(
|
||||||
toolbar: LayoutBuilder(
|
toolbar: _FiltersBar(
|
||||||
builder: (context, constraints) {
|
searchController: _searchController,
|
||||||
return _FiltersBar(
|
query: state.query,
|
||||||
searchController: _searchController,
|
statusOptions: _statusFilterOptions(state.orders),
|
||||||
query: state.query,
|
showExport: canExport,
|
||||||
statusOptions: _statusFilterOptions(state.orders),
|
isExporting: state.isExporting,
|
||||||
wrapped: constraints.maxWidth < 900,
|
onExport: _exportPurchaseOrders,
|
||||||
showExport: canExport,
|
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
|
||||||
isExporting: state.isExporting,
|
onStatusChanged:
|
||||||
onExport: _exportPurchaseOrders,
|
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
|
||||||
onSearch: ref.read(purchaseOrdersListProvider.notifier).setSearch,
|
onPoTypeChanged:
|
||||||
onStatusChanged:
|
ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
|
||||||
ref.read(purchaseOrdersListProvider.notifier).setStatusFilter,
|
|
||||||
onPoTypeChanged:
|
|
||||||
ref.read(purchaseOrdersListProvider.notifier).setPoTypeFilter,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
footer: AppPagination(
|
footer: AppPagination(
|
||||||
currentPage: state.query.page,
|
currentPage: state.query.page,
|
||||||
@ -275,7 +271,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.statusOptions,
|
required this.statusOptions,
|
||||||
required this.wrapped,
|
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
required this.onPoTypeChanged,
|
required this.onPoTypeChanged,
|
||||||
@ -287,7 +282,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final PurchaseOrderListQuery query;
|
final PurchaseOrderListQuery query;
|
||||||
final List<AppDropdownOption<String?>> statusOptions;
|
final List<AppDropdownOption<String?>> statusOptions;
|
||||||
final bool wrapped;
|
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
final ValueChanged<String?> onPoTypeChanged;
|
final ValueChanged<String?> onPoTypeChanged;
|
||||||
@ -327,48 +321,22 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final exportButton = OutlinedButton.icon(
|
return AppResponsiveFilterBar(
|
||||||
onPressed: isExporting ? null : onExport,
|
search: searchField,
|
||||||
icon: isExporting
|
filters: filters,
|
||||||
? const SizedBox(
|
trailing: showExport
|
||||||
width: 18,
|
? OutlinedButton.icon(
|
||||||
height: 18,
|
onPressed: isExporting ? null : onExport,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
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),
|
: null,
|
||||||
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,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -381,26 +381,78 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
setState(() {});
|
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) {
|
void _onItemChanged(int? itemId) {
|
||||||
_updateLine(() {
|
_updateLine(() {
|
||||||
widget.line.itemId = itemId;
|
widget.line.itemId = itemId;
|
||||||
if (itemId == null) return;
|
if (itemId == null) return;
|
||||||
final key = itemId.toString();
|
final key = itemId.toString();
|
||||||
final defaultUom = widget.itemUomById[key];
|
final defaultUom = _itemUomById[key];
|
||||||
if (defaultUom != null) {
|
if (defaultUom != null) {
|
||||||
widget.line.uomId = defaultUom;
|
widget.line.uomId = defaultUom;
|
||||||
}
|
}
|
||||||
final defaultGst = widget.itemGstRateById[key];
|
final defaultGst = _itemGstRateById[key];
|
||||||
if (defaultGst != null) {
|
if (defaultGst != null) {
|
||||||
widget.line.gstRateId = defaultGst;
|
widget.line.gstRateId = defaultGst;
|
||||||
}
|
}
|
||||||
final defaultHsn = widget.itemHsnById[key];
|
final defaultHsn = _itemHsnById[key];
|
||||||
if (defaultHsn != null) {
|
if (defaultHsn != null) {
|
||||||
widget.line.hsnCodeId = defaultHsn;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@ -467,8 +519,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
hint: 'Select item',
|
hint: 'Select item',
|
||||||
searchHint: 'Search item name or code...',
|
searchHint: 'Search item name or code...',
|
||||||
options: itemOptions,
|
options: itemOptions,
|
||||||
refreshLookups: () =>
|
refreshLookups: () async {
|
||||||
ref.invalidate(purchaseOrderLookupsProvider),
|
ref.invalidate(purchaseOrderLookupsProvider);
|
||||||
|
await ref.read(purchaseOrderLookupsProvider.future);
|
||||||
|
},
|
||||||
parseCreatedId: int.tryParse,
|
parseCreatedId: int.tryParse,
|
||||||
onChanged: _onItemChanged,
|
onChanged: _onItemChanged,
|
||||||
validator: (v) => v == null ? 'Item is required' : null,
|
validator: (v) => v == null ? 'Item is required' : null,
|
||||||
@ -500,8 +554,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
hint: 'Select UOM',
|
hint: 'Select UOM',
|
||||||
searchHint: 'Search UOM...',
|
searchHint: 'Search UOM...',
|
||||||
options: uomOptions,
|
options: uomOptions,
|
||||||
refreshLookups: () =>
|
refreshLookups: () async {
|
||||||
ref.invalidate(purchaseOrderLookupsProvider),
|
ref.invalidate(purchaseOrderLookupsProvider);
|
||||||
|
await ref.read(purchaseOrderLookupsProvider.future);
|
||||||
|
},
|
||||||
parseCreatedId: int.tryParse,
|
parseCreatedId: int.tryParse,
|
||||||
onChanged: (v) => _updateLine(() => line.uomId = v),
|
onChanged: (v) => _updateLine(() => line.uomId = v),
|
||||||
validator: (v) => v == null ? 'Required' : null,
|
validator: (v) => v == null ? 'Required' : null,
|
||||||
@ -543,8 +599,10 @@ class _LineItemCardState extends ConsumerState<_LineItemCard> {
|
|||||||
hint: 'Select',
|
hint: 'Select',
|
||||||
searchHint: 'Search GST %...',
|
searchHint: 'Search GST %...',
|
||||||
options: gstOptions,
|
options: gstOptions,
|
||||||
refreshLookups: () =>
|
refreshLookups: () async {
|
||||||
ref.invalidate(purchaseOrderLookupsProvider),
|
ref.invalidate(purchaseOrderLookupsProvider);
|
||||||
|
await ref.read(purchaseOrderLookupsProvider.future);
|
||||||
|
},
|
||||||
parseCreatedId: int.tryParse,
|
parseCreatedId: int.tryParse,
|
||||||
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
onChanged: (v) => _updateLine(() => line.gstRateId = v),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/user_management_models.dart';
|
||||||
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
import '../../../masters/data/datasources/master_remote_data_source.dart';
|
||||||
import '../../../roles/data/repositories/role_repository_impl.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 plants = await masterRemote.listPlants();
|
||||||
final designations = await masterRemote.listDesignations();
|
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!;
|
if (usersResult.failure != null) throw usersResult.failure!;
|
||||||
|
|
||||||
final managers = usersResult.data!.items
|
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))
|
.map((user) => FilterOptionModel(id: user.id, name: user.fullName))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import '../../../../shared/providers/permissions_provider.dart';
|
|||||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||||
import '../../../users/presentation/widgets/user_rich_data_table.dart';
|
import '../../../users/presentation/widgets/user_rich_data_table.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.dart';
|
import '../../../../shared/widgets/app_pagination.dart';
|
||||||
|
import '../../../../shared/widgets/app_responsive_filter_bar.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.dart';
|
import '../../../../shared/widgets/app_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_data_table.dart';
|
import '../../../../shared/widgets/app_data_table.dart';
|
||||||
@ -715,44 +716,38 @@ class _UsersTabState extends ConsumerState<_UsersTab> {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: LayoutBuilder(
|
child: _UsersFilterBar(
|
||||||
builder: (context, constraints) {
|
roleFilter: roleFilter,
|
||||||
final useWrappedFilters = constraints.maxWidth < 1000;
|
departmentFilter: departmentFilter,
|
||||||
return _UsersFilterBar(
|
statusFilter: statusFilter,
|
||||||
wrapped: useWrappedFilters,
|
roles: roles,
|
||||||
roleFilter: roleFilter,
|
departments: departments,
|
||||||
departmentFilter: departmentFilter,
|
statuses: statuses,
|
||||||
statusFilter: statusFilter,
|
isExporting: usersState.isExporting,
|
||||||
roles: roles,
|
showExport: canExport,
|
||||||
departments: departments,
|
searchController: _searchController,
|
||||||
statuses: statuses,
|
onExport: _exportUsers,
|
||||||
isExporting: usersState.isExporting,
|
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
||||||
showExport: canExport,
|
onRoleChanged: (value) {
|
||||||
searchController: _searchController,
|
ref.read(usersListProvider.notifier).setRoleFilter(
|
||||||
onExport: _exportUsers,
|
value == 'All Roles'
|
||||||
onSearch: ref.read(usersListProvider.notifier).setSearch,
|
? null
|
||||||
onRoleChanged: (value) {
|
: _roleIdForName(value, filters),
|
||||||
ref.read(usersListProvider.notifier).setRoleFilter(
|
);
|
||||||
value == 'All Roles'
|
},
|
||||||
? null
|
onDepartmentChanged: (value) {
|
||||||
: _roleIdForName(value, filters),
|
ref.read(usersListProvider.notifier).setDepartmentFilter(
|
||||||
);
|
value == 'All Departments'
|
||||||
},
|
? null
|
||||||
onDepartmentChanged: (value) {
|
: _departmentIdForName(value, filters),
|
||||||
ref.read(usersListProvider.notifier).setDepartmentFilter(
|
);
|
||||||
value == 'All Departments'
|
},
|
||||||
? null
|
onStatusChanged: (value) {
|
||||||
: _departmentIdForName(value, filters),
|
ref.read(usersListProvider.notifier).setStatusFilter(
|
||||||
);
|
value == 'All Statuses'
|
||||||
},
|
? null
|
||||||
onStatusChanged: (value) {
|
: _statusValueForLabel(value, filters),
|
||||||
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 {
|
class _UsersFilterBar extends StatelessWidget {
|
||||||
const _UsersFilterBar({
|
const _UsersFilterBar({
|
||||||
required this.wrapped,
|
|
||||||
required this.roleFilter,
|
required this.roleFilter,
|
||||||
required this.departmentFilter,
|
required this.departmentFilter,
|
||||||
required this.statusFilter,
|
required this.statusFilter,
|
||||||
@ -824,7 +818,6 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool wrapped;
|
|
||||||
final String roleFilter;
|
final String roleFilter;
|
||||||
final String departmentFilter;
|
final String departmentFilter;
|
||||||
final String statusFilter;
|
final String statusFilter;
|
||||||
@ -878,56 +871,26 @@ class _UsersFilterBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final exportButton = Padding(
|
return AppResponsiveFilterBar(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
search: searchField,
|
||||||
child: OutlinedButton.icon(
|
filters: filters,
|
||||||
onPressed: isExporting ? null : onExport,
|
trailing: showExport
|
||||||
style: OutlinedButton.styleFrom(
|
? OutlinedButton.icon(
|
||||||
minimumSize: const Size(0, 48),
|
onPressed: isExporting ? null : onExport,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
style: OutlinedButton.styleFrom(
|
||||||
),
|
minimumSize: const Size(0, 48),
|
||||||
icon: isExporting
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? const SizedBox(
|
),
|
||||||
width: 18,
|
icon: isExporting
|
||||||
height: 18,
|
? const SizedBox(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
width: 18,
|
||||||
)
|
height: 18,
|
||||||
: const Icon(Icons.download_outlined, size: 18),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
label: Text(isExporting ? 'Exporting...' : 'Export'),
|
)
|
||||||
),
|
: const Icon(Icons.download_outlined, size: 18),
|
||||||
);
|
label: Text(isExporting ? 'Exporting...' : 'Export'),
|
||||||
|
)
|
||||||
if (wrapped) {
|
: null,
|
||||||
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,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1314,7 +1277,7 @@ class _PermissionMatrixTabState extends ConsumerState<_PermissionMatrixTab> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Role permission matrix',
|
'Role Permission Matrix',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
@ -1426,7 +1389,7 @@ class _PermissionMatrixTable extends ConsumerWidget {
|
|||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Module',
|
label: 'Module',
|
||||||
flex: 3,
|
flex: 3,
|
||||||
searchText: (module) => '${module.name} ${module.code}',
|
enableSearch: false,
|
||||||
cellBuilder: (context, module) {
|
cellBuilder: (context, module) {
|
||||||
final index = matrix.modules.indexOf(module);
|
final index = matrix.modules.indexOf(module);
|
||||||
final appearance =
|
final appearance =
|
||||||
@ -1465,10 +1428,7 @@ class _PermissionMatrixTable extends ConsumerWidget {
|
|||||||
label: permissionActionLabel(action),
|
label: permissionActionLabel(action),
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
searchText: (module) {
|
enableSearch: false,
|
||||||
final checked = module.granted[action] ?? false;
|
|
||||||
return checked ? 'yes granted true' : 'no denied false';
|
|
||||||
},
|
|
||||||
cellBuilder: (context, module) {
|
cellBuilder: (context, module) {
|
||||||
final checked = module.granted[action] ?? false;
|
final checked = module.granted[action] ?? false;
|
||||||
return Checkbox(
|
return Checkbox(
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
|
||||||
class ReportFilterOption {
|
class ReportFilterOption {
|
||||||
@ -65,6 +66,10 @@ class DepreciationReportFilters {
|
|||||||
final raw = json[key];
|
final raw = json[key];
|
||||||
if (raw is List && raw.isNotEmpty) {
|
if (raw is List && raw.isNotEmpty) {
|
||||||
return raw
|
return raw
|
||||||
|
.where((item) {
|
||||||
|
if (item is! Map) return true;
|
||||||
|
return isActiveOptionRow(Map<String, dynamic>.from(item));
|
||||||
|
})
|
||||||
.map(ReportFilterOption.fromDynamic)
|
.map(ReportFilterOption.fromDynamic)
|
||||||
.where((o) => o.value.isNotEmpty)
|
.where((o) => o.value.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@ -18,10 +18,12 @@ import '../../../../shared/widgets/app_empty_state.dart';
|
|||||||
import '../../../../shared/widgets/app_filter_date_field.dart';
|
import '../../../../shared/widgets/app_filter_date_field.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_table_shell.dart';
|
import '../../../../shared/widgets/app_table_shell.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
|
import '../../../../shared/widgets/app_search_field.dart';
|
||||||
import '../../domain/entities/depreciation_report.dart';
|
import '../../domain/entities/depreciation_report.dart';
|
||||||
import '../providers/depreciation_report_provider.dart';
|
import '../providers/depreciation_report_provider.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
@ -158,7 +160,7 @@ class _DepreciationReportScreenState
|
|||||||
)
|
)
|
||||||
: const Icon(Icons.download_outlined),
|
: const Icon(Icons.download_outlined),
|
||||||
label: Text(
|
label: Text(
|
||||||
state.isExporting ? 'Exporting...' : 'Export CSV',
|
state.isExporting ? 'Exporting...' : 'Export',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -167,32 +169,27 @@ class _DepreciationReportScreenState
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableShell(
|
child: AppTableShell(
|
||||||
toolbar: LayoutBuilder(
|
toolbar: _FiltersBar(
|
||||||
builder: (context, constraints) {
|
searchController: _searchController,
|
||||||
return _FiltersBar(
|
filters: state.filters,
|
||||||
searchController: _searchController,
|
query: state.query,
|
||||||
filters: state.filters,
|
onSearch: notifier.setSearch,
|
||||||
query: state.query,
|
onPlantChanged: notifier.setPlantId,
|
||||||
wrapped: constraints.maxWidth < 1100,
|
onCategoryChanged: notifier.setItemCategoryId,
|
||||||
onSearch: notifier.setSearch,
|
onSubcategoryChanged: notifier.setItemSubcategoryId,
|
||||||
onPlantChanged: notifier.setPlantId,
|
onDepartmentChanged: notifier.setDepartmentId,
|
||||||
onCategoryChanged: notifier.setItemCategoryId,
|
onStatusChanged: notifier.setStatus,
|
||||||
onSubcategoryChanged: notifier.setItemSubcategoryId,
|
onMethodChanged: notifier.setDepreciationMethod,
|
||||||
onDepartmentChanged: notifier.setDepartmentId,
|
onIsActiveChanged: notifier.setIsActive,
|
||||||
onStatusChanged: notifier.setStatus,
|
onPickAsOfDate: () => _pickAsOfDate(state.query),
|
||||||
onMethodChanged: notifier.setDepreciationMethod,
|
onClearAsOfDate: () => notifier.setAsOfDate(null),
|
||||||
onIsActiveChanged: notifier.setIsActive,
|
onPickPurchaseRange: () =>
|
||||||
onPickAsOfDate: () => _pickAsOfDate(state.query),
|
_pickPurchaseDateRange(state.query),
|
||||||
onClearAsOfDate: () => notifier.setAsOfDate(null),
|
onClearPurchaseRange: () =>
|
||||||
onPickPurchaseRange: () =>
|
notifier.setPurchaseDateRange(null, null),
|
||||||
_pickPurchaseDateRange(state.query),
|
onReset: () {
|
||||||
onClearPurchaseRange: () =>
|
_searchController.clear();
|
||||||
notifier.setPurchaseDateRange(null, null),
|
notifier.resetFilters();
|
||||||
onReset: () {
|
|
||||||
_searchController.clear();
|
|
||||||
notifier.resetFilters();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
footer: AppPagination(
|
footer: AppPagination(
|
||||||
@ -326,7 +323,6 @@ class _FiltersBar extends StatefulWidget {
|
|||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.filters,
|
required this.filters,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.wrapped,
|
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onPlantChanged,
|
required this.onPlantChanged,
|
||||||
required this.onCategoryChanged,
|
required this.onCategoryChanged,
|
||||||
@ -345,7 +341,6 @@ class _FiltersBar extends StatefulWidget {
|
|||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final DepreciationReportFilters filters;
|
final DepreciationReportFilters filters;
|
||||||
final DepreciationReportQuery query;
|
final DepreciationReportQuery query;
|
||||||
final bool wrapped;
|
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onPlantChanged;
|
final ValueChanged<String?> onPlantChanged;
|
||||||
final ValueChanged<String?> onCategoryChanged;
|
final ValueChanged<String?> onCategoryChanged;
|
||||||
@ -399,24 +394,11 @@ class _FiltersBarState extends State<_FiltersBar> {
|
|||||||
.where((o) => o.parentId == query.itemCategoryId)
|
.where((o) => o.parentId == query.itemCategoryId)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final searchField = TextField(
|
final searchField = AppSearchField(
|
||||||
controller: widget.searchController,
|
controller: widget.searchController,
|
||||||
|
hint: 'Search asset code or name...',
|
||||||
onChanged: widget.onSearch,
|
onChanged: widget.onSearch,
|
||||||
decoration: InputDecoration(
|
onClear: () => widget.onSearch(''),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget dropdown({
|
Widget dropdown({
|
||||||
@ -536,19 +518,6 @@ class _FiltersBarState extends State<_FiltersBar> {
|
|||||||
child: const Text('Reset'),
|
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(
|
final actions = Row(
|
||||||
children: [
|
children: [
|
||||||
moreButton,
|
moreButton,
|
||||||
@ -556,58 +525,36 @@ class _FiltersBarState extends State<_FiltersBar> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.wrapped) {
|
final moreFilters = _moreOpen
|
||||||
return Column(
|
? Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
searchField,
|
Divider(
|
||||||
const SizedBox(height: 12),
|
height: 1,
|
||||||
plant,
|
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
category,
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
AppResponsiveFilterGrid(
|
||||||
asOfField,
|
fields: [
|
||||||
const SizedBox(height: 4),
|
subcategory,
|
||||||
actions,
|
department,
|
||||||
if (_moreOpen) ...[
|
status,
|
||||||
const SizedBox(height: 8),
|
method,
|
||||||
Divider(color: theme.colorScheme.outline.withValues(alpha: 0.2)),
|
active,
|
||||||
const SizedBox(height: 8),
|
purchaseField,
|
||||||
subcategory,
|
],
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
department,
|
],
|
||||||
const SizedBox(height: 12),
|
)
|
||||||
status,
|
: null;
|
||||||
const SizedBox(height: 12),
|
|
||||||
method,
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
active,
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
purchaseField,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
return AppResponsiveFilterGrid(
|
||||||
children: [
|
fields: [searchField, plant, category, asOfField],
|
||||||
row([searchField, plant, category, asOfField]),
|
footer: Align(
|
||||||
const SizedBox(height: 4),
|
alignment: Alignment.centerRight,
|
||||||
Align(alignment: Alignment.centerRight, child: actions),
|
child: actions,
|
||||||
if (_moreOpen) ...[
|
),
|
||||||
const SizedBox(height: 8),
|
extra: moreFilters,
|
||||||
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(),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -664,26 +611,6 @@ class _ReportTable extends StatelessWidget {
|
|||||||
textAlign: TextAlign.right,
|
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(
|
AppDataColumn(
|
||||||
label: 'Annual',
|
label: 'Annual',
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/permission_matrix_models.dart';
|
import '../../../../shared/models/permission_matrix_models.dart';
|
||||||
import '../../../../shared/models/role_model.dart';
|
import '../../../../shared/models/role_model.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
@ -42,13 +43,25 @@ class RoleRemoteDataSource {
|
|||||||
if (search != null && search.isNotEmpty) 'search': search,
|
if (search != null && search.isNotEmpty) 'search': search,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return _parseList(
|
final raw = response.data['data'];
|
||||||
response.data['data'],
|
final list = raw is List
|
||||||
(json) => FilterOptionModel(
|
? raw
|
||||||
id: json['id']?.toString() ?? '',
|
: raw is Map
|
||||||
name: json['name'] as String? ?? '',
|
? (raw['items'] as List?) ?? const []
|
||||||
),
|
: const [];
|
||||||
).where((role) => role.id.isNotEmpty && role.name.isNotEmpty).toList();
|
|
||||||
|
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 {
|
Future<RoleModel> getRoleById(String id) async {
|
||||||
|
|||||||
@ -91,7 +91,7 @@ class _MatrixGrid extends ConsumerWidget {
|
|||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Module',
|
label: 'Module',
|
||||||
flex: 3,
|
flex: 3,
|
||||||
searchText: (row) => row.name,
|
enableSearch: false,
|
||||||
cellBuilder: (_, row) => AppTableCell.text(row.name),
|
cellBuilder: (_, row) => AppTableCell.text(row.name),
|
||||||
),
|
),
|
||||||
...matrix.actionColumns.map(
|
...matrix.actionColumns.map(
|
||||||
@ -99,10 +99,7 @@ class _MatrixGrid extends ConsumerWidget {
|
|||||||
label: permissionActionLabel(action),
|
label: permissionActionLabel(action),
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
searchText: (row) {
|
enableSearch: false,
|
||||||
final checked = row.granted[action] ?? false;
|
|
||||||
return checked ? 'yes granted true' : 'no denied false';
|
|
||||||
},
|
|
||||||
cellBuilder: (_, row) => Checkbox(
|
cellBuilder: (_, row) => Checkbox(
|
||||||
value: row.granted[action] ?? false,
|
value: row.granted[action] ?? false,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../core/utils/responsive_utils.dart';
|
import '../../../../core/utils/responsive_utils.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../domain/entities/app_settings.dart';
|
import '../../domain/entities/app_settings.dart';
|
||||||
@ -117,10 +118,9 @@ class SettingsSwitchTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SwitchListTile(
|
return AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: title,
|
||||||
title: Text(title),
|
subtitle: subtitle,
|
||||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
|
import '../../../../core/utils/active_option.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/export_file_result.dart';
|
import '../../../../shared/models/export_file_result.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
@ -31,9 +32,11 @@ class UserRemoteDataSource {
|
|||||||
|
|
||||||
List<FilterOptionModel> parseIdNameOptions(List<dynamic>? list) {
|
List<FilterOptionModel> parseIdNameOptions(List<dynamic>? list) {
|
||||||
return (list ?? [])
|
return (list ?? [])
|
||||||
.map(
|
.whereType<Map>()
|
||||||
(item) => FilterOptionModel.fromJson(item as Map<String, dynamic>),
|
.map((item) => Map<String, dynamic>.from(item))
|
||||||
)
|
.where(isActiveOptionRow)
|
||||||
|
.map(FilterOptionModel.fromJson)
|
||||||
|
.where((item) => item.id.isNotEmpty && item.name.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../../../../shared/widgets/app_dropdown.dart';
|
|||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_search_field.dart';
|
||||||
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
@ -272,29 +273,9 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (context.isMobile) {
|
return AppResponsiveFilterBar(
|
||||||
return Column(
|
search: searchField,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
filters: dropdowns,
|
||||||
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]),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import '../../../../shared/widgets/app_searchable_dropdown.dart';
|
|||||||
import '../../../../shared/widgets/app_empty_state.dart';
|
import '../../../../shared/widgets/app_empty_state.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_pagination.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_search_field.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/app_table_action_icon.dart';
|
import '../../../../shared/widgets/app_table_action_icon.dart';
|
||||||
@ -90,22 +91,17 @@ class _VendorListScreenState extends ConsumerState<VendorListScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AppTableShell(
|
child: AppTableShell(
|
||||||
toolbar: LayoutBuilder(
|
toolbar: _FiltersBar(
|
||||||
builder: (context, constraints) {
|
searchController: _searchController,
|
||||||
return _FiltersBar(
|
query: state.query,
|
||||||
searchController: _searchController,
|
showExport: canExport,
|
||||||
query: state.query,
|
isExporting: state.isExporting,
|
||||||
wrapped: constraints.maxWidth < 900,
|
onExport: _exportVendors,
|
||||||
showExport: canExport,
|
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
|
||||||
isExporting: state.isExporting,
|
onStatusChanged:
|
||||||
onExport: _exportVendors,
|
ref.read(vendorsListProvider.notifier).setStatusFilter,
|
||||||
onSearch: ref.read(vendorsListProvider.notifier).setSearch,
|
onVendorTypeChanged:
|
||||||
onStatusChanged:
|
ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
|
||||||
ref.read(vendorsListProvider.notifier).setStatusFilter,
|
|
||||||
onVendorTypeChanged:
|
|
||||||
ref.read(vendorsListProvider.notifier).setVendorTypeFilter,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
footer: AppPagination(
|
footer: AppPagination(
|
||||||
currentPage: state.query.page,
|
currentPage: state.query.page,
|
||||||
@ -209,7 +205,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
const _FiltersBar({
|
const _FiltersBar({
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.wrapped,
|
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onStatusChanged,
|
required this.onStatusChanged,
|
||||||
required this.onVendorTypeChanged,
|
required this.onVendorTypeChanged,
|
||||||
@ -220,7 +215,6 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
|
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final VendorListQuery query;
|
final VendorListQuery query;
|
||||||
final bool wrapped;
|
|
||||||
final ValueChanged<String> onSearch;
|
final ValueChanged<String> onSearch;
|
||||||
final ValueChanged<String?> onStatusChanged;
|
final ValueChanged<String?> onStatusChanged;
|
||||||
final ValueChanged<String?> onVendorTypeChanged;
|
final ValueChanged<String?> onVendorTypeChanged;
|
||||||
@ -265,48 +259,22 @@ class _FiltersBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
final exportButton = OutlinedButton.icon(
|
return AppResponsiveFilterBar(
|
||||||
onPressed: isExporting ? null : onExport,
|
search: searchField,
|
||||||
icon: isExporting
|
filters: filters,
|
||||||
? const SizedBox(
|
trailing: showExport
|
||||||
width: 18,
|
? OutlinedButton.icon(
|
||||||
height: 18,
|
onPressed: isExporting ? null : onExport,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
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),
|
: null,
|
||||||
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,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import '../../../../shared/models/user_management_models.dart';
|
|||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_searchable_dropdown.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
@ -329,9 +330,8 @@ class _VendorFormPanelState extends ConsumerState<VendorFormPanel> {
|
|||||||
),
|
),
|
||||||
if (_showActiveSwitch) ...[
|
if (_showActiveSwitch) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (v) => setState(() => _isActive = v),
|
onChanged: (v) => setState(() => _isActive = v),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import '../../../../core/utils/validators.dart';
|
|||||||
import '../../../../shared/models/vendor_model.dart';
|
import '../../../../shared/models/vendor_model.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_dropdown.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_side_panel.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../providers/vendors_provider.dart';
|
import '../providers/vendors_provider.dart';
|
||||||
@ -196,9 +197,8 @@ class _VendorAddressPanelState extends ConsumerState<VendorAddressPanel> {
|
|||||||
inputFormatters: Validators.gstinInput,
|
inputFormatters: Validators.gstinInput,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (v) => setState(() => _isActive = v),
|
onChanged: (v) => setState(() => _isActive = v),
|
||||||
),
|
),
|
||||||
@ -325,15 +325,13 @@ class _VendorContactPanelState extends ConsumerState<VendorContactPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Primary Contact',
|
||||||
title: const Text('Primary Contact'),
|
|
||||||
value: _isPrimary,
|
value: _isPrimary,
|
||||||
onChanged: (v) => setState(() => _isPrimary = v),
|
onChanged: (v) => setState(() => _isPrimary = v),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (v) => setState(() => _isActive = v),
|
onChanged: (v) => setState(() => _isActive = v),
|
||||||
),
|
),
|
||||||
@ -491,15 +489,13 @@ class _VendorBankDetailPanelState extends ConsumerState<VendorBankDetailPanel> {
|
|||||||
onChanged: (v) => setState(() => _accountType = v),
|
onChanged: (v) => setState(() => _accountType = v),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Primary Account',
|
||||||
title: const Text('Primary Account'),
|
|
||||||
value: _isPrimary,
|
value: _isPrimary,
|
||||||
onChanged: (v) => setState(() => _isPrimary = v),
|
onChanged: (v) => setState(() => _isPrimary = v),
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: 'Active',
|
||||||
title: const Text('Active'),
|
|
||||||
value: _isActive,
|
value: _isActive,
|
||||||
onChanged: (v) => setState(() => _isActive = v),
|
onChanged: (v) => setState(() => _isActive = v),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -42,12 +42,6 @@ const List<MenuItem> appMenuItems = [
|
|||||||
// route: RouteConstants.branches,
|
// route: RouteConstants.branches,
|
||||||
// module: 'branches',
|
// module: 'branches',
|
||||||
// ),
|
// ),
|
||||||
MenuItem(
|
|
||||||
label: 'Users & Roles',
|
|
||||||
icon: Icons.admin_panel_settings_outlined,
|
|
||||||
route: RouteConstants.usersRoleManagement,
|
|
||||||
module: 'users',
|
|
||||||
),
|
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Assets',
|
label: 'Assets',
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
@ -86,18 +80,24 @@ const List<MenuItem> appMenuItems = [
|
|||||||
route: RouteConstants.grn,
|
route: RouteConstants.grn,
|
||||||
module: 'grn',
|
module: 'grn',
|
||||||
),
|
),
|
||||||
MenuItem(
|
|
||||||
label: 'Master Data',
|
|
||||||
icon: Icons.dataset_outlined,
|
|
||||||
route: RouteConstants.masterData,
|
|
||||||
module: 'master_data',
|
|
||||||
),
|
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
icon: Icons.assessment_outlined,
|
icon: Icons.assessment_outlined,
|
||||||
route: RouteConstants.reports,
|
route: RouteConstants.reports,
|
||||||
module: '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(
|
MenuItem(
|
||||||
label: 'Audit Logs',
|
label: 'Audit Logs',
|
||||||
icon: Icons.history_outlined,
|
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({
|
List<MenuItem> getVisibleMenuItems({
|
||||||
required List<String> permissions,
|
required List<String> permissions,
|
||||||
required UserRole role,
|
required UserRole role,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class AppFilterDateField extends StatelessWidget {
|
|||||||
this.placeholder = 'Select date',
|
this.placeholder = 'Select date',
|
||||||
this.onClear,
|
this.onClear,
|
||||||
this.isEmpty = false,
|
this.isEmpty = false,
|
||||||
|
this.alignWithLabeledFields = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
@ -22,6 +23,7 @@ class AppFilterDateField extends StatelessWidget {
|
|||||||
final String placeholder;
|
final String placeholder;
|
||||||
final VoidCallback? onClear;
|
final VoidCallback? onClear;
|
||||||
final bool isEmpty;
|
final bool isEmpty;
|
||||||
|
final bool alignWithLabeledFields;
|
||||||
|
|
||||||
static const _suffixConstraints = BoxConstraints(
|
static const _suffixConstraints = BoxConstraints(
|
||||||
minWidth: 40,
|
minWidth: 40,
|
||||||
@ -35,7 +37,7 @@ class AppFilterDateField extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final showClear = onClear != null && !isEmpty;
|
final showClear = onClear != null && !isEmpty;
|
||||||
|
|
||||||
return InputDecorator(
|
final field = InputDecorator(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
labelText: label,
|
||||||
isDense: true,
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_responsive_filter_bar.dart';
|
||||||
|
|
||||||
/// Search field with export action — matches the users list toolbar (no filters).
|
/// Search field with export action — matches the users list toolbar (no filters).
|
||||||
class AppSearchExportBar extends StatelessWidget {
|
class AppSearchExportBar extends StatelessWidget {
|
||||||
const AppSearchExportBar({
|
const AppSearchExportBar({
|
||||||
@ -8,6 +10,7 @@ class AppSearchExportBar extends StatelessWidget {
|
|||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
required this.onExport,
|
required this.onExport,
|
||||||
this.isExporting = false,
|
this.isExporting = false,
|
||||||
|
@Deprecated('Layout is always responsive via AppResponsiveFilterBar')
|
||||||
this.wrapped = false,
|
this.wrapped = false,
|
||||||
this.searchController,
|
this.searchController,
|
||||||
this.searchWidth = 320,
|
this.searchWidth = 320,
|
||||||
@ -35,42 +38,21 @@ class AppSearchExportBar extends StatelessWidget {
|
|||||||
onChanged: onSearch,
|
onChanged: onSearch,
|
||||||
);
|
);
|
||||||
|
|
||||||
final exportButton = OutlinedButton.icon(
|
return AppResponsiveFilterBar(
|
||||||
onPressed: isExporting ? null : onExport,
|
search: searchField,
|
||||||
icon: isExporting
|
trailing: showExport
|
||||||
? const SizedBox(
|
? OutlinedButton.icon(
|
||||||
width: 18,
|
onPressed: isExporting ? null : onExport,
|
||||||
height: 18,
|
icon: isExporting
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
? 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),
|
: null,
|
||||||
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,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -230,6 +230,7 @@ class _AppSearchableDropdownState<T> extends State<AppSearchableDropdown<T>> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: widget.label,
|
labelText: widget.label,
|
||||||
hintText: displayLabel == null ? effectiveHint : null,
|
hintText: displayLabel == null ? effectiveHint : null,
|
||||||
|
hintMaxLines: 1,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
isDense: widget.isDense,
|
isDense: widget.isDense,
|
||||||
errorText: field.errorText,
|
errorText: field.errorText,
|
||||||
@ -612,6 +613,7 @@ class _AppSearchableLookupFieldState<T>
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: widget.label,
|
labelText: widget.label,
|
||||||
hintText: displayLabel == null ? effectiveHint : null,
|
hintText: displayLabel == null ? effectiveHint : null,
|
||||||
|
hintMaxLines: 1,
|
||||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||||
isDense: widget.isDense,
|
isDense: widget.isDense,
|
||||||
suffixIcon: Icon(
|
suffixIcon: Icon(
|
||||||
|
|||||||
@ -57,8 +57,16 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
final Set<String> _expandedMenus = <String>{};
|
final Set<String> _expandedMenus = <String>{};
|
||||||
final Set<String> _manuallyCollapsedMenus = <String>{};
|
final Set<String> _manuallyCollapsedMenus = <String>{};
|
||||||
|
|
||||||
List<menu.MenuItem> get _mainMenuItems =>
|
List<menu.MenuItem> get _primaryMenuItems => widget.menuItems
|
||||||
widget.menuItems.where((item) => item.route != RouteConstants.settings).toList();
|
.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) {
|
bool _routeMatches(String route) {
|
||||||
final current = widget.currentRoute;
|
final current = widget.currentRoute;
|
||||||
@ -104,7 +112,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
void didUpdateWidget(covariant AppSidebar oldWidget) {
|
void didUpdateWidget(covariant AppSidebar oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.currentRoute != widget.currentRoute) {
|
if (oldWidget.currentRoute != widget.currentRoute) {
|
||||||
for (final item in _mainMenuItems) {
|
for (final item in [..._primaryMenuItems, ..._supportMenuItems]) {
|
||||||
if (item.children.isNotEmpty && _isGroupActive(item)) {
|
if (item.children.isNotEmpty && _isGroupActive(item)) {
|
||||||
_expandedMenus.add(item.route);
|
_expandedMenus.add(item.route);
|
||||||
_manuallyCollapsedMenus.remove(item.route);
|
_manuallyCollapsedMenus.remove(item.route);
|
||||||
@ -167,84 +175,25 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (!isNarrow)
|
if (isNarrow) const SizedBox(height: 4),
|
||||||
const _SectionLabel(label: 'MAIN MENU')
|
..._buildNavItems(_primaryMenuItems, isNarrow: isNarrow),
|
||||||
else
|
if (_hasSupportSection) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 16),
|
||||||
..._mainMenuItems.map((item) {
|
if (!isNarrow) const _SectionLabel(label: 'SUPPORT'),
|
||||||
if (isNarrow && item.children.isNotEmpty) {
|
if (AppConstants.showNotificationsMenu)
|
||||||
return _CollapsedFlyoutNavItem(
|
_SidebarNavItem(
|
||||||
item: item,
|
icon: Icons.notifications_outlined,
|
||||||
selected: _isGroupActive(item),
|
label: 'Notifications',
|
||||||
isChildSelected: (route) =>
|
selected: false,
|
||||||
_isSelectedAmongSiblings(route, item.children),
|
collapsed: isNarrow,
|
||||||
onChildTap: widget.onItemTap,
|
badge: isNarrow ? null : '3',
|
||||||
);
|
onTap: () {},
|
||||||
}
|
),
|
||||||
|
..._buildNavItems(
|
||||||
if (item.children.isNotEmpty && !isNarrow) {
|
_supportMenuItems,
|
||||||
final expanded = _isGroupExpanded(item);
|
isNarrow: isNarrow,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -259,8 +208,66 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasSettings =>
|
List<Widget> _buildNavItems(
|
||||||
widget.menuItems.any((item) => item.route == RouteConstants.settings);
|
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}) {
|
Widget _buildHeader(BuildContext context, {required bool isNarrow}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/utils/validators.dart';
|
import '../../core/utils/validators.dart';
|
||||||
import '../../modules/master_data/data/repositories/master_repository_impl.dart';
|
import '../../modules/master_data/data/repositories/master_repository_impl.dart';
|
||||||
import '../../modules/master_data/domain/entities/master_definition.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.
|
/// Compact master create form for use inside a dropdown panel.
|
||||||
class MasterInlineQuickAddForm extends ConsumerStatefulWidget {
|
class MasterInlineQuickAddForm extends ConsumerStatefulWidget {
|
||||||
@ -176,10 +177,8 @@ class _MasterInlineQuickAddFormState
|
|||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case MasterFieldType.boolean:
|
case MasterFieldType.boolean:
|
||||||
if (field.key == 'is_active') {
|
if (field.key == 'is_active') {
|
||||||
return SwitchListTile(
|
return AppFormToggleField(
|
||||||
contentPadding: EdgeInsets.zero,
|
label: field.label,
|
||||||
dense: true,
|
|
||||||
title: Text(field.label, style: const TextStyle(fontSize: 13)),
|
|
||||||
value: _values[field.key] == true,
|
value: _values[field.key] == true,
|
||||||
onChanged: _submitting
|
onChanged: _submitting
|
||||||
? null
|
? null
|
||||||
@ -253,6 +252,9 @@ class _MasterInlineQuickAddFormState
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final fields = _definition.formFields;
|
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(
|
return Material(
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||||
@ -297,7 +299,7 @@ class _MasterInlineQuickAddFormState
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else ...[
|
else ...[
|
||||||
for (final field in fields) ...[
|
for (final field in regularFields) ...[
|
||||||
_buildField(field),
|
_buildField(field),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@ -312,25 +314,40 @@ class _MasterInlineQuickAddFormState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
if (activeField != null)
|
||||||
TextButton(
|
Expanded(
|
||||||
onPressed: _submitting ? null : widget.onCancel,
|
child: Align(
|
||||||
child: const Text('Cancel'),
|
alignment: Alignment.centerLeft,
|
||||||
),
|
child: _buildField(activeField),
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
FilledButton(
|
)
|
||||||
onPressed: _submitting ? null : _submit,
|
else
|
||||||
child: _submitting
|
const Spacer(),
|
||||||
? const SizedBox(
|
const SizedBox(width: 12),
|
||||||
width: 16,
|
Row(
|
||||||
height: 16,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: CircularProgressIndicator(
|
children: [
|
||||||
strokeWidth: 2,
|
TextButton(
|
||||||
color: Colors.white,
|
onPressed: _submitting ? null : widget.onCancel,
|
||||||
),
|
child: const Text('Cancel'),
|
||||||
)
|
),
|
||||||
: Text('Add ${_definition.title}'),
|
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:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -31,6 +34,7 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final isLight = theme.brightness == Brightness.light;
|
||||||
final fallback = Image.asset(
|
final fallback = Image.asset(
|
||||||
AppConstants.defaultLogoAsset,
|
AppConstants.defaultLogoAsset,
|
||||||
width: _width,
|
width: _width,
|
||||||
@ -43,7 +47,7 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final content = _buildLogoContent(fallback);
|
final content = _buildLogoContent(fallback, knockOutBlack: isLight);
|
||||||
|
|
||||||
if (!showBackground) {
|
if (!showBackground) {
|
||||||
return SizedBox(width: _width, height: _height, child: content);
|
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);
|
final url = resolveMediaUrl(logoUrl);
|
||||||
if (url == null || url.isEmpty) {
|
if (url == null || url.isEmpty) {
|
||||||
return fallback;
|
return _maybeKnockOut(fallbackProvider: const AssetImage(AppConstants.defaultLogoAsset), fallback: fallback, knockOutBlack: knockOutBlack);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith('assets/')) {
|
if (url.startsWith('assets/')) {
|
||||||
return Image.asset(
|
final image = Image.asset(
|
||||||
url,
|
url,
|
||||||
width: _width,
|
width: _width,
|
||||||
height: _height,
|
height: _height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
errorBuilder: (_, __, ___) => fallback,
|
errorBuilder: (_, __, ___) => fallback,
|
||||||
);
|
);
|
||||||
|
return _maybeKnockOut(
|
||||||
|
fallbackProvider: AssetImage(url),
|
||||||
|
fallback: image,
|
||||||
|
knockOutBlack: knockOutBlack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith('data:image')) {
|
if (url.startsWith('data:image')) {
|
||||||
try {
|
try {
|
||||||
final base64Str = url.contains(',') ? url.split(',').last : url;
|
final base64Str = url.contains(',') ? url.split(',').last : url;
|
||||||
return Image.memory(
|
final bytes = base64Decode(base64Str);
|
||||||
base64Decode(base64Str),
|
final image = Image.memory(
|
||||||
|
bytes,
|
||||||
width: _width,
|
width: _width,
|
||||||
height: _height,
|
height: _height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
errorBuilder: (_, __, ___) => fallback,
|
errorBuilder: (_, __, ___) => fallback,
|
||||||
);
|
);
|
||||||
|
return _maybeKnockOut(
|
||||||
|
fallbackProvider: MemoryImage(bytes),
|
||||||
|
fallback: image,
|
||||||
|
knockOutBlack: knockOutBlack,
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
return CachedNetworkImage(
|
final image = CachedNetworkImage(
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
width: _width,
|
width: _width,
|
||||||
height: _height,
|
height: _height,
|
||||||
@ -107,10 +122,224 @@ class SidebarLogo extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => fallback,
|
errorWidget: (_, __, ___) => fallback,
|
||||||
);
|
);
|
||||||
|
return _maybeKnockOut(
|
||||||
|
fallbackProvider: CachedNetworkImageProvider(url),
|
||||||
|
fallback: image,
|
||||||
|
knockOutBlack: knockOutBlack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return fallback;
|
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.
|
/// Resolves logo URL from company profile, branding, or bundled default.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user